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
This commit is contained in:
Kent Wang
2026-04-16 22:26:39 +08:00
parent 92c6aecea5
commit fa960e1353
20 changed files with 1069 additions and 455 deletions

View File

@@ -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<WorkspaceMeta>(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<string, boolean> = {};
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<WorkspaceMeta>(models.workspaceMeta.type, {
parentId: {
$in: projectWorkspaces.map(w => w._id),
},
}),
database.find<ApiSpec>(models.apiSpec.type, {
parentId: {
$in: projectWorkspaces.map(w => w._id),
},
}),
database.find<MockServer>(models.mockServer.type, {
parentId: {
$in: projectWorkspaces.map(w => w._id),
},
}),
]);
const gitRepositories = await database.find<GitRepository>(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<Workspace>(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>('Project', {
parentId: organizationId,
});
const gitRepositoryIds = projects.map(p => p.gitRepositoryId).filter(isNotNullOrUndefined);
const gitRepositories = await database.find<GitRepository>('GitRepository', {
_id: {
$in: gitRepositoryIds,
},
});
return projects.map(project => {
const gitRepository = gitRepositories.find(gr => gr._id === project.gitRepositoryId);
return {
...project,
gitRepository,
};
});
}

View File

@@ -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<Record<string, boolean>>;
}
/**
* 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>('Project', {
parentId: organizationId,
});
const gitRepositoryIds = projects.map(p => p.gitRepositoryId).filter(isNotNullOrUndefined);
const gitRepositories = await database.find<GitRepository>('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<WorkspaceMeta>(models.workspaceMeta.type, {
parentId: {
$in: projectWorkspaces.map(w => w._id),
},
}),
database.find<ApiSpec>(models.apiSpec.type, {
parentId: {
$in: projectWorkspaces.map(w => w._id),
},
}),
database.find<MockServer>(models.mockServer.type, {
parentId: {
$in: projectWorkspaces.map(w => w._id),
},
}),
]);
const gitRepositories = await database.find<GitRepository>(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<Workspace>(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<WorkspaceMeta>(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<string, boolean> = {};
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<typeof clientLoader>('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;

View File

@@ -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);

View File

@@ -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<WorkspaceMeta>(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<string, boolean> = {};
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<LearningFeature>(learningFeaturePromise);
const [remoteFiles] = useLoaderDeferData<InsomniaFile[]>(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) && <CloudSyncProjectBar />}
</div>
</Panel>
<PanelResizeHandle className="relative z-10 h-full w-px bg-(--hl-md)" hitAreaMargins={{ coarse: 20, fine: 20 }} />
<PanelResizeHandle
className="relative z-10 h-full w-px bg-(--hl-md)"
hitAreaMargins={{ coarse: 15, fine: 15 }}
/>
<Panel id="pane-one" className="pane-one theme--pane flex flex-col">
<Outlet />
</Panel>

View File

@@ -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<string>(`${workspaceId}:collection-list-filter`);
const [filter, setFilter] = reactUse.useLocalStorage<string>(`${workspaceId}:collection-list-filter`);
const collection = useFilteredRequests(_collection, filter ?? '');
const isDesignWorkspace = models.workspace.isDesign(activeWorkspace);

View File

@@ -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:

View File

@@ -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';

View File

@@ -39,7 +39,6 @@ interface Props {
apiSpec?: ApiSpec;
mockServer?: MockServer;
project: Project;
projects: Project[];
}
const useDocumentActionPlugins = ({ workspace, apiSpec, project }: Props) => {

View File

@@ -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;

View File

@@ -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<string, unknown> = {}): 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<string, unknown> = {}): 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<typeof filterCollection>[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);
});
});

View File

@@ -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)';

View File

@@ -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<Map<string, InsomniaFile[]>>(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<Map<string, Workspace[]>>(new Map());
// ref to cache queried collection children (request & requestGroups) data and meta by workspace id
const cachedCollectionChildrenAndMetaRef = useRef<Map<string, AllRequestsAndMetaInWorkspace>>(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<string, InsomniaFile[]>();
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 ? (
<Button
aria-label="Cancel sync"
onPress={() => cancelSync()}
onPress={cancelSync}
className="flex h-full items-center justify-center gap-1 rounded-xs px-2 text-sm text-(--color-font) ring-1 ring-transparent transition-all hover:bg-(--hl-xs) focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm)"
>
Cancel
@@ -572,9 +671,8 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
);
}
}}
onPress={e => {
const { doc } = item;
const docId = doc._id;
onPress={async e => {
const docId = item.doc._id;
if (item.kind === 'project') {
if (routeInfo?.resourceId === docId) {
toggleProjectOrWorkspace(docId);
@@ -632,6 +730,7 @@ export const ProjectNavigationSidebar = ({ storageRules, konnectSyncEnabled }: P
{item.kind === 'workspace' && <WorkspaceNode item={item} onToggle={toggleProjectOrWorkspace} />}
{item.kind === 'collectionChild' && <RequestNode item={item} onToggleFolder={toggleRequestGroups} />}
{item.kind === 'unsyncedWorkspace' && <UnsyncedWorkspaceNode item={item} />}
</GridListItem>
);
}}

View File

@@ -1,14 +1,7 @@
import { useState } from 'react';
import { Button } from 'react-aria-components';
import type {
GrpcRequest,
McpRequest,
Request,
RequestGroup,
SocketIORequest,
WebSocketRequest,
} from '~/insomnia-data';
import type { GrpcRequest, McpRequest, Request, SocketIORequest, WebSocketRequest } from '~/insomnia-data';
import { models } from '~/insomnia-data';
import { RequestActionsDropdown } from '~/ui/components/dropdowns/request-actions-dropdown';
import { RequestGroupActionsDropdown } from '~/ui/components/dropdowns/request-group-actions-dropdown';
@@ -80,7 +73,7 @@ export const RequestNode = ({ item, onToggleFolder }: RequestNodeProps) => {
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
return (
<div className={ROW_CLASS} style={{ paddingLeft: `${level + 3}rem`, paddingRight: '8px' }}>
<div className={ROW_CLASS} style={{ paddingLeft: `${level + 3}rem` }}>
{Array.from({ length: level + 2 }, (_, i) => {
const isActive = i === level + 1;
return (

View File

@@ -1,3 +1,4 @@
import type { InsomniaFile } from '~/common/project';
import type { GitRepository, Project, Workspace, WorkspaceMeta } from '~/insomnia-data';
import type { BaseModel } from '~/models/types';
import type { Child } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
@@ -36,6 +37,15 @@ export interface WorkspaceFlatItem extends BaseFlatItem<Workspace> {
// parent project
project: ProjectWithPresence;
}
//unsynced workspace in clod sync project
type UnsyncedWorkspaceDoc = InsomniaFile & { _id: string };
export type UnsyncedWorkspaceFlatItem = Exclude<BaseFlatItem<any>, 'doc'> &
Pick<WorkspaceFlatItem, 'project'> & {
kind: 'unsyncedWorkspace';
doc: UnsyncedWorkspaceDoc;
};
// Collection child items including all kinds of request and request group (folder)
export interface CollectionChildFlatItem extends BaseFlatItem<Child['doc']> {
kind: 'collectionChild';
@@ -50,4 +60,4 @@ export interface CollectionChildFlatItem extends BaseFlatItem<Child['doc']> {
pinned: boolean;
}
export type FlatItem = ProjectFlatItem | WorkspaceFlatItem | CollectionChildFlatItem;
export type FlatItem = ProjectFlatItem | WorkspaceFlatItem | CollectionChildFlatItem | UnsyncedWorkspaceFlatItem;

View File

@@ -0,0 +1,64 @@
import { useEffect } from 'react';
import { Button } from 'react-aria-components';
import { useInsomniaSyncPullRemoteFileActionFetcher } from '~/routes/organization.$organizationId.insomnia-sync.pull-remote-file';
import { showToast } from '~/ui/components/toast-notification';
import { Icon } from '../../icon';
import {
ACTIVE_BORDER_CLASS,
GUIDE_LINE_CSS,
ICON_CLASS,
ROW_CLASS,
TOGGLE_BTN_CLASS,
} from './project-navigation-sidebar-utils';
import type { UnsyncedWorkspaceFlatItem } from './types';
export const UnsyncedWorkspaceNode = ({ item }: { item: UnsyncedWorkspaceFlatItem }) => {
const pullRemoteFileFetcher = useInsomniaSyncPullRemoteFileActionFetcher();
const isPulling = pullRemoteFileFetcher.state !== 'idle';
useEffect(() => {
if (
pullRemoteFileFetcher.data &&
'error' in pullRemoteFileFetcher.data &&
pullRemoteFileFetcher.data.error &&
pullRemoteFileFetcher.state === 'idle'
) {
const error: string =
pullRemoteFileFetcher.data.error || `An unexpected error occurred while fetching remote file ${item.doc.name}.`;
showToast({
title: 'Failed to fetch remote workspace',
icon: 'star',
status: 'error',
description: `There was an error communicating with the AI service. Please try again. ${error}`,
});
}
}, [pullRemoteFileFetcher.data, pullRemoteFileFetcher.state, item.doc.name]);
return (
<div className={`${ROW_CLASS} group`} style={{ paddingLeft: '2em' }}>
<span className={ACTIVE_BORDER_CLASS} />
<span className={`${GUIDE_LINE_CSS} group-hover/tree:bg-(--hl-sm)`} style={{ left: '1.5em' }} />
<Button className={TOGGLE_BTN_CLASS} aria-label="" isDisabled />
<Button
onPress={() => {
const { project, doc, organizationId } = item;
const { remoteId: backendProjectId } = doc;
if (project.remoteId && backendProjectId) {
pullRemoteFileFetcher.submit({
backendProjectId,
remoteId: project.remoteId,
organizationId,
});
}
}}
isDisabled={isPulling}
className={`flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xs px-2 py-1 text-left opacity-60 transition-colors ${isPulling ? 'animate-pulse cursor-not-allowed' : ''}`}
>
<Icon icon={isPulling ? 'spinner' : 'cloud-download'} className={ICON_CLASS} spin={isPulling} />
<span>{item.doc.name}</span>
</Button>
</div>
);
};

View File

@@ -25,13 +25,15 @@ const getSelectedItemId = (resources?: NavigationResources) => {
};
export const useProjectNavigationSidebarNavigation = ({
setExpandedProjectAndWorkspaceIds,
setActiveTab,
toggleRequestGroups,
expandProjectOrWorkspaces,
visibleFlatItems,
virtualizer,
}: {
setExpandedProjectAndWorkspaceIds: Dispatch<SetStateAction<string[] | undefined>>;
setActiveTab: Dispatch<SetStateAction<'projects' | 'konnect' | undefined>>;
toggleRequestGroups: (requestGroupIds: string[], collapsed?: boolean) => Promise<void>;
expandProjectOrWorkspaces: (ids: string[]) => void;
visibleFlatItems: FlatItem[];
virtualizer: Virtualizer<HTMLDivElement, Element>;
}) => {
@@ -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) {

View File

@@ -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 (
<div className={`${ROW_CLASS} group`} style={{ paddingLeft: '2em' }}>
@@ -28,12 +29,10 @@ export const WorkspaceNode = ({ item, onToggle }: WorkspaceNodeProps) => {
<span className={`${GUIDE_LINE_CSS} group-hover/tree:bg-(--hl-sm)`} style={{ left: '1.5em' }} />
<Button
aria-label={`${collapsed ? 'Expand' : 'Collapse'} ${workspaceName}`}
onPress={() => onToggle(workspaceId)}
onPress={() => isCollection && onToggle(workspaceId)}
className={TOGGLE_BTN_CLASS}
>
{workspaceScope === 'collection' ? (
<Icon icon={collapsed ? 'chevron-right' : 'chevron-down'} className={ICON_CLASS} />
) : null}
{isCollection ? <Icon icon={collapsed ? 'chevron-right' : 'chevron-down'} className={ICON_CLASS} /> : null}
</Button>
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden rounded-xs px-2 py-1 text-left transition-colors">
<Icon icon={scopeToIconMap[workspaceScope]} className={ICON_CLASS} />

View File

@@ -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<PropsWithChildren> = ({ 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<PropsWithChildren> = ({ 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 => {

View File

@@ -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<UIEventType, EventHandler[]> = {
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

View File

@@ -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}`;