refine loader behavior

This commit is contained in:
Kent Wang
2026-04-16 22:26:39 +08:00
parent ffbd413424
commit aed415c3c7
13 changed files with 734 additions and 335 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,209 @@ 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 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

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

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

@@ -15,6 +15,7 @@ import {
import { useParams, useRevalidator } from 'react-router';
import * as reactUse from 'react-use';
import type { InsomniaFile } from '~/common/project';
import type { GitProject, GitRepository } from '~/insomnia-data';
import { isScratchpadOrganizationId } from '~/models/organization';
import { useGitProjectCheckoutBranchActionFetcher } from '~/routes/git.branch.checkout';

View File

@@ -0,0 +1,462 @@
import type { IconName, IconProp } from '@fortawesome/fontawesome-svg-core';
import {
exportGlobalEnvironmentToFile,
exportMcpClientToFile,
exportMockServerToFile,
} from 'insomnia/src/ui/components/settings/import-export';
import React, { Fragment, useState } from 'react';
import {
Button,
Collection,
Dialog,
Header,
Heading,
Label,
Menu,
MenuItem,
MenuSection,
MenuTrigger,
Modal,
ModalOverlay,
Popover,
Radio,
RadioGroup,
} from 'react-aria-components';
import { href } from 'react-router';
import type { Project, Workspace } from '~/insomnia-data';
import { models } from '~/insomnia-data';
import { useRequestNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.new';
import { useRequestGroupNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request-group.new';
import { useWorkspaceDeleteActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.delete';
import { useWorkspaceUpdateActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.update';
import { useTabNavigate } from '~/ui/hooks/use-insomnia-tab';
import type { CreateRequestType } from '~/ui/hooks/use-request';
import { getProductName } from '../../../common/constants';
import { getWorkspaceLabel } from '../../../common/get-workspace-label';
import type { PlatformKeyCombinations } from '../../../common/settings';
import { SegmentEvent } from '../../analytics';
import { DropdownHint } from '../base/dropdown/dropdown-hint';
import { Icon } from '../icon';
import { showModal } from '../modals';
import { ExportRequestsModal } from '../modals/export-requests-modal';
import { ImportModal } from '../modals/import-modal/import-modal';
import { PasteCurlModal } from '../modals/paste-curl-modal';
import { PromptModal } from '../modals/prompt-modal';
import { WorkspaceDuplicateModal } from '../modals/workspace-duplicate-modal';
import { WorkspaceSettingsModal } from '../modals/workspace-settings-modal';
interface Props {
workspace: Workspace;
project: Project;
organizationId: string;
}
interface ActionItem {
id: string;
name: string;
icon: IconName;
hint?: PlatformKeyCombinations;
action: () => void;
className?: string;
}
interface ActionSection {
name: string;
id: string;
icon: IconProp;
items: ActionItem[];
}
export const SidebarWorkspaceDropdown = ({ workspace, project, organizationId }: Props) => {
const projectId = project._id;
const workspaceId = workspace._id;
const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isPasteCurlModalOpen, setPasteCurlModalOpen] = useState(false);
const updateWorkspaceFetcher = useWorkspaceUpdateActionFetcher();
const deleteWorkspaceFetcher = useWorkspaceDeleteActionFetcher();
const newRequestFetcher = useRequestNewActionFetcher();
const newRequestGroupFetcher = useRequestGroupNewActionFetcher();
const tabNavigate = useTabNavigate();
const workspaceName = workspace.name;
const projectName = project.name || getProductName();
const isCollection = workspace.scope === 'collection';
const createRequest = (requestType: CreateRequestType) => {
newRequestFetcher.submit({
organizationId,
projectId,
workspaceId,
requestType,
parentId: workspaceId,
});
};
const openInNewTab = (isRunner = false) => {
tabNavigate(
{ organization: organizationId, project, workspace, item: workspace },
isRunner ? { shouldNavigate: true, asRunner: true } : { withTab: true, shouldNavigate: true },
);
};
const createSections: ActionSection[] = isCollection
? [
{
name: 'Create',
id: 'create',
icon: 'plus',
items: [
{
id: 'New Folder',
name: 'New Folder',
icon: 'folder',
action: () =>
showModal(PromptModal, {
title: 'New Folder',
defaultValue: 'My Folder',
submitName: 'Create',
label: 'Name',
selectText: true,
onComplete: (name: string) =>
newRequestGroupFetcher.submit({
organizationId,
projectId,
workspaceId,
parentId: workspaceId,
name,
}),
}),
},
{
id: 'HTTP',
name: 'HTTP Request',
icon: 'plus-circle',
action: () => createRequest('HTTP'),
},
{
id: 'Event Stream',
name: 'Event Stream Request (SSE)',
icon: 'plus-circle',
action: () => createRequest('Event Stream'),
},
{
id: 'GraphQL Request',
name: 'GraphQL Request',
icon: 'plus-circle',
action: () => createRequest('GraphQL'),
},
{
id: 'gRPC Request',
name: 'gRPC Request',
icon: 'plus-circle',
action: () => createRequest('gRPC'),
},
{
id: 'WebSocket Request',
name: 'WebSocket Request',
icon: 'plus-circle',
action: () => createRequest('WebSocket'),
},
{
id: 'Socket.IO Request',
name: 'Socket.IO Request',
icon: 'plus-circle',
action: () => createRequest('SocketIO'),
},
],
},
{
name: 'Import',
id: 'import-create',
icon: 'file-import',
items: [
{
id: 'From Curl',
name: 'From Curl',
icon: 'terminal',
action: () => setPasteCurlModalOpen(true),
},
{
id: 'from-file',
name: 'From File',
icon: 'file-import',
action: () => setIsImportModalOpen(true),
},
],
},
{
name: 'Run',
id: 'run',
icon: 'circle-play',
items: [
{
id: 'RunCollection',
name: 'Run Collection',
icon: 'circle-play',
action: () => openInNewTab(true),
},
],
},
]
: [];
const actionSection: ActionSection = {
name: 'Actions',
id: 'actions',
icon: 'cog',
items: [
{
id: 'OpenInNewTab',
name: 'Open in New Tab',
icon: 'external-link-alt',
action: openInNewTab,
},
...(!models.workspace.isMcp(workspace)
? [
{
id: 'Duplicate',
name: 'Duplicate / Move',
icon: 'copy' as IconName,
action: () => setIsDuplicateModalOpen(true),
},
]
: []),
{
id: 'Rename',
name: 'Rename',
icon: 'pen-to-square' as IconName,
action: () =>
showModal(PromptModal, {
title: `Rename ${getWorkspaceLabel(workspace).singular}`,
defaultValue: workspaceName,
submitName: 'Rename',
selectText: true,
label: 'Name',
onComplete: (name: string) =>
updateWorkspaceFetcher.submit({
organizationId,
projectId,
patch: { name, workspaceId },
}),
}),
},
{
id: 'Export',
name: 'Export',
icon: 'file-export',
action: () => {
window.main.trackSegmentEvent({
event: SegmentEvent.exportStarted,
properties: { source: `${workspace.scope}-list` },
});
if (workspace.scope === 'mock-server') {
return exportMockServerToFile(workspace);
}
if (workspace.scope === 'environment') {
return exportGlobalEnvironmentToFile(workspace);
}
if (workspace.scope === 'mcp') {
return exportMcpClientToFile(workspace);
}
return setIsExportModalOpen(true);
},
},
{
id: 'Settings',
name: 'Settings',
icon: 'gear',
action: () => setIsSettingsModalOpen(true),
},
{
id: 'Delete',
name: 'Delete',
icon: 'trash',
className: 'text-(--color-danger)',
action: () => setIsDeleteModalOpen(true),
},
],
};
const allSections: ActionSection[] = [...createSections, actionSection];
return (
<Fragment>
<MenuTrigger>
<Button
aria-label="SideBar Workspace Actions"
className="hidden aspect-square h-6 items-center justify-center rounded-xs text-sm text-(--color-font) opacity-0 ring-1 ring-transparent transition-all group-hover:flex group-hover:opacity-100 group-focus:flex group-focus:opacity-100 hover:bg-(--hl-xs) hover:opacity-100 focus:opacity-100 focus:ring-(--hl-md) focus:ring-inset aria-pressed:bg-(--hl-sm) data-pressed:flex data-pressed:opacity-100"
>
<Icon icon="ellipsis" />
</Button>
<Popover className="flex min-w-max flex-col overflow-y-hidden" placement="bottom end">
<Menu
aria-label="Workspace Actions Menu"
selectionMode="single"
onAction={key =>
allSections
.find(s => s.items.find(a => a.id === key))
?.items.find(a => a.id === key)
?.action()
}
items={allSections}
className="min-w-max overflow-y-auto rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) py-2 text-sm shadow-lg select-none focus:outline-hidden"
>
{section => (
<MenuSection className="flex flex-1 flex-col">
<Header className="flex items-center gap-2 py-1 pl-2 text-xs text-(--hl) uppercase">
<Icon icon={section.icon} /> <span>{section.name}</span>
</Header>
<Collection items={section.items}>
{item => (
<MenuItem
key={item.id}
id={item.id}
className={`flex h-(--line-height-xs) w-full items-center gap-2 bg-transparent px-(--padding-md) whitespace-nowrap text-(--color-font) transition-colors hover:bg-(--hl-sm) focus:bg-(--hl-xs) focus:outline-hidden disabled:cursor-not-allowed aria-selected:font-bold${item.className ? ` ${item.className}` : ''}`}
aria-label={item.name}
>
<Icon icon={item.icon} />
<span>{item.name}</span>
{item.hint && <DropdownHint keyBindings={item.hint} />}
</MenuItem>
)}
</Collection>
</MenuSection>
)}
</Menu>
</Popover>
</MenuTrigger>
{isDuplicateModalOpen && (
<WorkspaceDuplicateModal workspace={workspace} onHide={() => setIsDuplicateModalOpen(false)} />
)}
{isImportModalOpen && (
<ImportModal
onHide={() => setIsImportModalOpen(false)}
from={{ type: 'file' }}
projectName={projectName}
workspaceName={workspaceName}
organizationId={organizationId}
defaultProjectId={projectId}
defaultWorkspaceId={workspaceId}
/>
)}
{isExportModalOpen && (
<ExportRequestsModal workspaceIdToExport={workspaceId} onClose={() => setIsExportModalOpen(false)} />
)}
{isSettingsModalOpen && (
<WorkspaceSettingsModal workspace={workspace} project={project} onClose={() => setIsSettingsModalOpen(false)} />
)}
{isDeleteModalOpen && (
<ModalOverlay
isOpen
onOpenChange={() => setIsDeleteModalOpen(false)}
isDismissable
className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-center justify-center bg-black/30"
>
<Modal
onOpenChange={() => setIsDeleteModalOpen(false)}
className="max-h-full w-full max-w-2xl rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) p-(--padding-lg) text-(--color-font)"
>
<Dialog className="outline-hidden">
{({ close }) => (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<Heading className="text-2xl">Delete {getWorkspaceLabel(workspace).singular}</Heading>
<Button
className="flex aspect-square h-6 shrink-0 items-center justify-center rounded-xs 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)"
onPress={close}
>
<Icon icon="x" />
</Button>
</div>
<deleteWorkspaceFetcher.Form
action={href(`/organization/:organizationId/project/:projectId/workspace/delete`, {
organizationId,
projectId: workspace.parentId,
})}
method="POST"
className="flex flex-col gap-4"
>
<input type="hidden" name="workspaceId" value={workspaceId} />
<div>
<p className="line-clamp-5">
This will permanently delete the{' '}
<strong className="break-all whitespace-pre-wrap">{workspaceName}</strong>{' '}
{getWorkspaceLabel(workspace).singular}
</p>
{models.project.isRemoteProject(project) && (
<RadioGroup name="localOnly" defaultValue="false" className="mb-2 flex flex-col gap-2">
<Label className="text-sm text-(--hl)">How do you want to delete it?</Label>
<div className="flex gap-2">
<Radio
value="true"
aria-label="Remove Local Copy"
className="flex-1 rounded-sm border border-solid border-(--hl-md) p-4 transition-colors hover:bg-(--hl-xs) focus:bg-(--hl-sm) focus:outline-hidden data-disabled:opacity-25 data-selected:border-(--color-surprise) data-selected:ring-2 data-selected:ring-(--color-surprise)"
>
<div>
<Heading className="text-lg font-bold">Remove Local Copy</Heading>
<p className="pt-2">The project will still exist on the Cloud.</p>
</div>
</Radio>
<Radio
value="false"
aria-label="Delete Permanently"
className="flex-1 rounded-sm border border-solid border-(--hl-md) p-4 transition-colors hover:bg-(--hl-xs) focus:bg-(--hl-sm) focus:outline-hidden data-disabled:opacity-25 data-selected:border-(--color-surprise) data-selected:ring-2 data-selected:ring-(--color-surprise)"
>
<div>
<Heading className="text-lg font-bold">Delete Permanently</Heading>
<p className="pt-2">
The project will be deleted everywhere. You cannot undo this action.
</p>
</div>
</Radio>
</div>
</RadioGroup>
)}
</div>
{deleteWorkspaceFetcher.data?.error && (
<p className="notice error margin-bottom-sm no-margin-top">{deleteWorkspaceFetcher.data.error}</p>
)}
<div className="flex justify-end">
<Button
type="submit"
aria-label="Delete Workspace"
className="rounded-xs border border-solid border-(--hl-md) bg-(--color-danger) px-3 py-2 text-(--color-font-danger) transition-colors hover:bg-(--color-danger)/90 hover:no-underline"
>
Delete
</Button>
</div>
</deleteWorkspaceFetcher.Form>
</div>
)}
</Dialog>
</Modal>
</ModalOverlay>
)}
{isPasteCurlModalOpen && (
<PasteCurlModal
onImport={req => {
newRequestFetcher.submit({
organizationId,
projectId,
workspaceId,
requestType: 'From Curl',
parentId: workspaceId,
req,
});
}}
onHide={() => setPasteCurlModalOpen(false)}
/>
)}
</Fragment>
);
};

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

@@ -6,6 +6,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router';
import * as reactUse from 'react-use';
import { fuzzyMatchAll } from '~/common/misc';
import type { InsomniaFile } from '~/common/project';
import type { RequestGroup, Workspace } from '~/insomnia-data';
import { models, services } from '~/insomnia-data';
import type { SyncResult } from '~/konnect/sync';
@@ -479,7 +480,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

View File

@@ -1,6 +1,7 @@
import { Button } from 'react-aria-components';
import { scopeToIconMap } from '~/common/get-workspace-label';
import { SidebarWorkspaceDropdown } from '~/ui/components/dropdowns/sidebar-workspace-dropdown';
import { Icon } from '../../icon';
import {
@@ -18,7 +19,7 @@ interface WorkspaceNodeProps {
}
export const WorkspaceNode = ({ item, onToggle }: WorkspaceNodeProps) => {
const { doc, collapsed } = item;
const { doc, collapsed, project, organizationId } = item;
const { name: workspaceName, _id: workspaceId, scope: workspaceScope } = doc;
return (
@@ -38,6 +39,9 @@ export const WorkspaceNode = ({ item, onToggle }: WorkspaceNodeProps) => {
<Icon icon={scopeToIconMap[workspaceScope]} className={ICON_CLASS} />
<span className="min-w-0 flex-1 truncate text-sm">{workspaceName}</span>
</div>
<div className="shrink-0">
<SidebarWorkspaceDropdown workspace={doc} project={project} organizationId={organizationId} />
</div>
</div>
);
};

View File

@@ -1,8 +1,6 @@
import type { Icon, IconProp } from '@fortawesome/fontawesome-svg-core';
import { useMemo } from 'react';
import { useParams } from 'react-router';
import { models } from '~/insomnia-data';
import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
import { useRequestLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId';
import { useRequestGroupLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request-group.$requestGroupId';

View File

@@ -6,7 +6,7 @@ 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';
@@ -93,7 +93,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;

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