From aed415c3c74e3349446dadc8bbee5da983f2eb2c Mon Sep 17 00:00:00 2001 From: Kent Wang Date: Thu, 16 Apr 2026 22:26:39 +0800 Subject: [PATCH] refine loader behavior --- packages/insomnia/src/common/project.ts | 244 +++++++++ ...ganizationId.project.$projectId._index.tsx | 322 +----------- ...nId.project.$projectId.list-workspaces.tsx | 2 +- ...projectId.workspace.$workspaceId.debug.tsx | 11 +- ...ization.$organizationId.project._index.tsx | 2 +- .../dropdowns/git-project-sync-dropdown.tsx | 1 + .../dropdowns/sidebar-workspace-dropdown.tsx | 462 ++++++++++++++++++ .../panes/scratchpad-tutorial-pane.tsx | 5 +- .../project-navigation-sidebar.tsx | 3 +- .../workspace-node.tsx | 6 +- .../workspace/use-workspace-breadcrumb.tsx | 2 - .../app/insomnia-event-stream-context.tsx | 4 +- .../insomnia/src/ui/hooks/use-vcs-version.ts | 5 +- 13 files changed, 734 insertions(+), 335 deletions(-) create mode 100644 packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx diff --git a/packages/insomnia/src/common/project.ts b/packages/insomnia/src/common/project.ts index dd0649794e..4e9aab8eb7 100644 --- a/packages/insomnia/src/common/project.ts +++ b/packages/insomnia/src/common/project.ts @@ -1,3 +1,41 @@ +import { parseApiSpec, type ParsedApiSpec } from '~/common/api-specs'; +import { scopeToLabelMap } from '~/common/get-workspace-label'; +import { isNotNullOrUndefined } from '~/common/misc'; +import { descendingNumberSort } from '~/common/sorting'; +import { + type ApiSpec, + database, + type GitRepository, + type MockServer, + models, + type Project, + services, + type Workspace, + type WorkspaceMeta, + type WorkspaceScope, +} from '~/insomnia-data'; +import { VCSInstance } from '~/sync/vcs/insomnia-sync'; + +export interface InsomniaFile { + id: string; + name: string; + remoteId?: string; + scope: WorkspaceScope | 'unsynced'; + label: 'Document' | 'Collection' | 'Mock Server' | 'Unsynced' | 'Environment' | 'MCP Client'; + created: number; + lastModifiedTimestamp: number; + branch?: string; + lastCommit?: string; + version?: string; + oasFormat?: string; + mockServer?: MockServer; + workspace?: Workspace; + apiSpec?: ApiSpec; + hasUncommittedChanges?: boolean; + hasUnpushedChanges?: boolean; + gitFilePath?: string | null; +} + const lockGenerator = () => { // Simple mutex lock implementation let isLocked = false; @@ -45,3 +83,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(models.workspaceMeta.type, { + parentId: { + $in: projectWorkspaces.map(w => w._id), + }, + }); + return workspaceMetas.some(item => item.hasUncommittedChanges || item.hasUnpushedChanges); +}; + +export const CheckAllProjectSyncStatus = async (projects: Project[]) => { + const taskList = projects.map(project => checkSingleProjectSyncStatus(project._id)); + const res = await Promise.all(taskList); + const obj: Record = {}; + projects.forEach((project, index) => { + obj[project._id] = res[index]; + }); + return obj; +}; + +export async function getAllLocalFiles({ projectId }: { projectId: string }) { + const projectWorkspaces = await services.workspace.findByParentId(projectId); + const [workspaceMetas, apiSpecs, mockServers] = await Promise.all([ + database.find(models.workspaceMeta.type, { + parentId: { + $in: projectWorkspaces.map(w => w._id), + }, + }), + database.find(models.apiSpec.type, { + parentId: { + $in: projectWorkspaces.map(w => w._id), + }, + }), + database.find(models.mockServer.type, { + parentId: { + $in: projectWorkspaces.map(w => w._id), + }, + }), + ]); + + const gitRepositories = await database.find(models.gitRepository.type, { + parentId: { + $in: workspaceMetas.map(wm => wm.gitRepositoryId).filter(isNotNullOrUndefined), + }, + }); + + const files: InsomniaFile[] = projectWorkspaces.map(workspace => { + const apiSpec = apiSpecs.find(spec => spec.parentId === workspace._id); + const mockServer = mockServers.find(mock => mock.parentId === workspace._id); + let spec: ParsedApiSpec['contents'] = null; + let specFormat: ParsedApiSpec['format'] = null; + let specFormatVersion: ParsedApiSpec['formatVersion'] = null; + if (apiSpec) { + try { + const result = parseApiSpec(apiSpec.contents); + spec = result.contents; + specFormat = result.format; + specFormatVersion = result.formatVersion; + } catch { + // Assume there is no spec + // TODO: Check for parse errors if it's an invalid spec + } + } + const workspaceMeta = workspaceMetas.find(wm => wm.parentId === workspace._id); + const gitRepository = gitRepositories.find(gr => gr._id === workspaceMeta?.gitRepositoryId); + + const lastActiveBranch = gitRepository?.cachedGitRepositoryBranch; + + const lastCommitAuthor = gitRepository?.cachedGitLastAuthor; + + // WorkspaceMeta is a good proxy for last modified time + const workspaceModified = workspaceMeta?.modified || workspace.modified; + + const modifiedLocally = models.workspace.isDesign(workspace) ? apiSpec?.modified || 0 : workspaceModified; + + // Span spec, workspace and sync related timestamps for card last modified label and sort order + const lastModifiedFrom = [ + workspace?.modified, + workspaceMeta?.modified, + modifiedLocally, + gitRepository?.cachedGitLastCommitTime, + ]; + + const lastModifiedTimestamp = lastModifiedFrom.filter(isNotNullOrUndefined).sort(descendingNumberSort)[0]; + + const hasUnsavedChanges = Boolean( + models.workspace.isDesign(workspace) && + gitRepository?.cachedGitLastCommitTime && + modifiedLocally > gitRepository?.cachedGitLastCommitTime, + ); + + const specVersion = spec?.info?.version ? String(spec?.info?.version) : ''; + + return { + id: workspace._id, + name: workspace.name, + scope: workspace.scope, + label: scopeToLabelMap[workspace.scope], + created: workspace.created, + lastModifiedTimestamp: + (hasUnsavedChanges && modifiedLocally) || gitRepository?.cachedGitLastCommitTime || lastModifiedTimestamp, + branch: lastActiveBranch || '', + lastCommit: + hasUnsavedChanges && gitRepository?.cachedGitLastCommitTime && lastCommitAuthor ? `by ${lastCommitAuthor}` : '', + version: specVersion ? `${specVersion?.startsWith('v') ? '' : 'v'}${specVersion}` : '', + oasFormat: specFormat ? `${specFormat === 'openapi' ? 'OpenAPI' : 'Swagger'} ${specFormatVersion || ''}` : '', + mockServer, + apiSpec, + workspace, + hasUncommittedChanges: workspaceMeta?.hasUncommittedChanges, + hasUnpushedChanges: workspaceMeta?.hasUnpushedChanges, + gitFilePath: workspaceMeta?.gitFilePath, + }; + }); + return files; +} + +export async function getAllRemoteFiles({ projectId, organizationId }: { projectId: string; organizationId: string }) { + try { + const project = await services.project.getById(projectId); + + const remoteId = project?.remoteId; + if (!remoteId) { + return []; + } + + console.log( + '[getAllRemoteFiles] start fetching remote backend workspaces for project', + projectId, + `remoteId: ${remoteId}`, + ); + + const vcs = VCSInstance(); + + const [allPulledBackendProjectsForRemoteId, allFetchedRemoteBackendProjectsForRemoteId] = await Promise.all([ + vcs.localBackendProjects().then(projects => projects.filter(p => p.id === remoteId)), + // Remote backend projects are fetched from the backend since they are not stored locally + vcs.remoteBackendProjects({ teamId: organizationId, teamProjectId: remoteId }), + ]); + console.log( + `[getAllRemoteFiles] found allPulledBackendProjectsForRemoteId: ${allPulledBackendProjectsForRemoteId.length} and allFetchedRemoteBackendProjectsForRemoteId: ${allFetchedRemoteBackendProjectsForRemoteId.length} for remoteId: ${remoteId}`, + ); + // Get all workspaces that are connected to backend projects and under the current project + const workspacesWithBackendProjects = await database.find(models.workspace.type, { + _id: { + $in: [...allPulledBackendProjectsForRemoteId, ...allFetchedRemoteBackendProjectsForRemoteId].map( + p => p.rootDocumentId, + ), + }, + parentId: project._id, + }); + console.log(`[getAllRemoteFiles] found workspacesWithBackendProjects: ${workspacesWithBackendProjects.length}`); + // Get the list of remote backend projects that we need to pull + const backendProjectsToPull = allFetchedRemoteBackendProjectsForRemoteId.filter( + p => !workspacesWithBackendProjects.find(w => w._id === p.rootDocumentId), + ); + console.log(`[getAllRemoteFiles] get ${backendProjectsToPull.length} unsynced files`); + return backendProjectsToPull.map(backendProject => { + const file: InsomniaFile = { + id: backendProject.rootDocumentId, + name: backendProject.name, + scope: 'unsynced', + label: 'Unsynced', + remoteId: backendProject.id, + created: 0, + lastModifiedTimestamp: 0, + }; + + return file; + }); + } catch (e) { + console.warn('Failed to load backend projects', e); + } + + return []; +} + +/** + * Get all projects for an organization with their associated git repositories + */ +export async function getProjectsWithGitRepositories({ + organizationId, +}: { + organizationId: string; +}): Promise<(Project & { gitRepository?: GitRepository })[]> { + const projects = await database.find('Project', { + parentId: organizationId, + }); + + const gitRepositoryIds = projects.map(p => p.gitRepositoryId).filter(isNotNullOrUndefined); + + const gitRepositories = await database.find('GitRepository', { + _id: { + $in: gitRepositoryIds, + }, + }); + + return projects.map(project => { + const gitRepository = gitRepositories.find(gr => gr._id === project.gitRepositoryId); + return { + ...project, + gitRepository, + }; + }); +} diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx index 95d1419c9f..2bbff592f3 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId._index.tsx @@ -16,41 +16,28 @@ import { Tooltip, TooltipTrigger, } from 'react-aria-components'; -import type { LoaderFunctionArgs } from 'react-router'; -import { href, redirect, useFetchers, useLoaderData, useParams, useRouteLoaderData } from 'react-router'; +import { useFetchers, useParams } from 'react-router'; import * as reactUse from 'react-use'; -import { logout } from '~/account/session'; -import { parseApiSpec, type ParsedApiSpec } from '~/common/api-specs'; import { DASHBOARD_SORT_ORDERS, type DashboardSortOrder, dashboardSortOrderName, getAppWebsiteBaseURL, } from '~/common/constants'; -import { database } from '~/common/database'; -import { scopeToBgColorMap, scopeToIconMap, scopeToLabelMap, scopeToTextColorMap } from '~/common/get-workspace-label'; -import { fuzzyMatchAll, isNotNullOrUndefined } from '~/common/misc'; -import { descendingNumberSort, sortMethodMap } from '~/common/sorting'; -import type { - ApiSpec, - GitRepository, - MockServer, - Project, - Workspace, - WorkspaceMeta, - WorkspaceScope, -} from '~/insomnia-data'; -import { services } from '~/insomnia-data'; +import { scopeToBgColorMap, scopeToIconMap, scopeToTextColorMap } from '~/common/get-workspace-label'; +import { fuzzyMatchAll } from '~/common/misc'; +import type { InsomniaFile } from '~/common/project'; +import { sortMethodMap } from '~/common/sorting'; +import type { GitRepository, Project, WorkspaceScope } from '~/insomnia-data'; import * as models from '~/models'; -import { sortProjects } from '~/models/helpers/project'; import { isOwnerOfOrganization, isPersonalOrganization, isScratchpadOrganizationId } from '~/models/organization'; import { useRootLoaderData } from '~/root'; import { useOrganizationLoaderData } from '~/routes/organization'; import { useInsomniaSyncPullRemoteFileActionFetcher } from '~/routes/organization.$organizationId.insomnia-sync.pull-remote-file'; +import { useProjectLoaderData } from '~/routes/organization.$organizationId.project.$projectId'; import { useWorkspaceNewActionFetcher } from '~/routes/organization.$organizationId.project.$projectId.workspace.new'; import { useStorageRulesLoaderFetcher } from '~/routes/organization.$organizationId.storage-rules'; -import { VCSInstance } from '~/sync/vcs/insomnia-sync'; import { SegmentEvent, trackOnceDaily } from '~/ui/analytics'; import { AvatarGroup } from '~/ui/components/avatar'; import { WorkspaceCardDropdown } from '~/ui/components/dropdowns/workspace-card-dropdown'; @@ -71,27 +58,6 @@ import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data'; import { useOrganizationPermissions } from '~/ui/hooks/use-organization-features'; import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils'; import { isPrimaryClickModifier } from '~/ui/utils'; -import { invariant } from '~/utils/invariant'; - -export interface InsomniaFile { - id: string; - name: string; - remoteId?: string; - scope: WorkspaceScope | 'unsynced'; - label: 'Document' | 'Collection' | 'Mock Server' | 'Unsynced' | 'Environment' | 'MCP Client'; - created: number; - lastModifiedTimestamp: number; - branch?: string; - lastCommit?: string; - version?: string; - oasFormat?: string; - mockServer?: MockServer; - workspace?: Workspace; - apiSpec?: ApiSpec; - hasUncommittedChanges?: boolean; - hasUnpushedChanges?: boolean; - gitFilePath?: string | null; -} export interface ProjectLoaderData { localFiles: InsomniaFile[]; @@ -109,281 +75,9 @@ export interface ProjectLoaderData { projectsSyncStatusPromise?: Promise>; } -/** - * Get all projects for an organization with their associated git repositories - */ -export async function getProjectsWithGitRepositories({ - organizationId, -}: { - organizationId: string; -}): Promise<(Project & { gitRepository?: GitRepository })[]> { - const projects = await database.find('Project', { - parentId: organizationId, - }); - - const gitRepositoryIds = projects.map(p => p.gitRepositoryId).filter(isNotNullOrUndefined); - - const gitRepositories = await database.find('GitRepository', { - _id: { - $in: gitRepositoryIds, - }, - }); - - return projects.map(project => { - const gitRepository = gitRepositories.find(gr => gr._id === project.gitRepositoryId); - return { - ...project, - gitRepository, - }; - }); -} - -async function getAllLocalFiles({ projectId }: { projectId: string }) { - const projectWorkspaces = await services.workspace.findByParentId(projectId); - const [workspaceMetas, apiSpecs, mockServers] = await Promise.all([ - database.find(models.workspaceMeta.type, { - parentId: { - $in: projectWorkspaces.map(w => w._id), - }, - }), - database.find(models.apiSpec.type, { - parentId: { - $in: projectWorkspaces.map(w => w._id), - }, - }), - database.find(models.mockServer.type, { - parentId: { - $in: projectWorkspaces.map(w => w._id), - }, - }), - ]); - - const gitRepositories = await database.find(models.gitRepository.type, { - parentId: { - $in: workspaceMetas.map(wm => wm.gitRepositoryId).filter(isNotNullOrUndefined), - }, - }); - - const files: InsomniaFile[] = projectWorkspaces.map(workspace => { - const apiSpec = apiSpecs.find(spec => spec.parentId === workspace._id); - const mockServer = mockServers.find(mock => mock.parentId === workspace._id); - let spec: ParsedApiSpec['contents'] = null; - let specFormat: ParsedApiSpec['format'] = null; - let specFormatVersion: ParsedApiSpec['formatVersion'] = null; - if (apiSpec) { - try { - const result = parseApiSpec(apiSpec.contents); - spec = result.contents; - specFormat = result.format; - specFormatVersion = result.formatVersion; - } catch { - // Assume there is no spec - // TODO: Check for parse errors if it's an invalid spec - } - } - const workspaceMeta = workspaceMetas.find(wm => wm.parentId === workspace._id); - const gitRepository = gitRepositories.find(gr => gr._id === workspaceMeta?.gitRepositoryId); - - const lastActiveBranch = gitRepository?.cachedGitRepositoryBranch; - - const lastCommitAuthor = gitRepository?.cachedGitLastAuthor; - - // WorkspaceMeta is a good proxy for last modified time - const workspaceModified = workspaceMeta?.modified || workspace.modified; - - const modifiedLocally = models.workspace.isDesign(workspace) ? apiSpec?.modified || 0 : workspaceModified; - - // Span spec, workspace and sync related timestamps for card last modified label and sort order - const lastModifiedFrom = [ - workspace?.modified, - workspaceMeta?.modified, - modifiedLocally, - gitRepository?.cachedGitLastCommitTime, - ]; - - const lastModifiedTimestamp = lastModifiedFrom.filter(isNotNullOrUndefined).sort(descendingNumberSort)[0]; - - const hasUnsavedChanges = Boolean( - models.workspace.isDesign(workspace) && - gitRepository?.cachedGitLastCommitTime && - modifiedLocally > gitRepository?.cachedGitLastCommitTime, - ); - - const specVersion = spec?.info?.version ? String(spec?.info?.version) : ''; - - return { - id: workspace._id, - name: workspace.name, - scope: workspace.scope, - label: scopeToLabelMap[workspace.scope], - created: workspace.created, - lastModifiedTimestamp: - (hasUnsavedChanges && modifiedLocally) || gitRepository?.cachedGitLastCommitTime || lastModifiedTimestamp, - branch: lastActiveBranch || '', - lastCommit: - hasUnsavedChanges && gitRepository?.cachedGitLastCommitTime && lastCommitAuthor ? `by ${lastCommitAuthor}` : '', - version: specVersion ? `${specVersion?.startsWith('v') ? '' : 'v'}${specVersion}` : '', - oasFormat: specFormat ? `${specFormat === 'openapi' ? 'OpenAPI' : 'Swagger'} ${specFormatVersion || ''}` : '', - mockServer, - apiSpec, - workspace, - hasUncommittedChanges: workspaceMeta?.hasUncommittedChanges, - hasUnpushedChanges: workspaceMeta?.hasUnpushedChanges, - gitFilePath: workspaceMeta?.gitFilePath, - }; - }); - return files; -} - -async function getAllRemoteFiles({ projectId, organizationId }: { projectId: string; organizationId: string }) { - try { - const project = await services.project.getById(projectId); - - const remoteId = project?.remoteId; - if (!remoteId) { - return []; - } - - console.log( - '[getAllRemoteFiles] start fetching remote backend workspaces for project', - projectId, - `remoteId: ${remoteId}`, - ); - - const vcs = VCSInstance(); - - const [allPulledBackendProjectsForRemoteId, allFetchedRemoteBackendProjectsForRemoteId] = await Promise.all([ - vcs.localBackendProjects().then(projects => projects.filter(p => p.id === remoteId)), - // Remote backend projects are fetched from the backend since they are not stored locally - vcs.remoteBackendProjects({ teamId: organizationId, teamProjectId: remoteId }), - ]); - console.log( - `[getAllRemoteFiles] found allPulledBackendProjectsForRemoteId: ${allPulledBackendProjectsForRemoteId.length} and allFetchedRemoteBackendProjectsForRemoteId: ${allFetchedRemoteBackendProjectsForRemoteId.length} for remoteId: ${remoteId}`, - ); - // Get all workspaces that are connected to backend projects and under the current project - const workspacesWithBackendProjects = await database.find(models.workspace.type, { - _id: { - $in: [...allPulledBackendProjectsForRemoteId, ...allFetchedRemoteBackendProjectsForRemoteId].map( - p => p.rootDocumentId, - ), - }, - parentId: project._id, - }); - console.log(`[getAllRemoteFiles] found workspacesWithBackendProjects: ${workspacesWithBackendProjects.length}`); - // Get the list of remote backend projects that we need to pull - const backendProjectsToPull = allFetchedRemoteBackendProjectsForRemoteId.filter( - p => !workspacesWithBackendProjects.find(w => w._id === p.rootDocumentId), - ); - console.log(`[getAllRemoteFiles] get ${backendProjectsToPull.length} unsynced files`); - return backendProjectsToPull.map(backendProject => { - const file: InsomniaFile = { - id: backendProject.rootDocumentId, - name: backendProject.name, - scope: 'unsynced', - label: 'Unsynced', - remoteId: backendProject.id, - created: 0, - lastModifiedTimestamp: 0, - }; - - return file; - }); - } catch (e) { - console.warn('Failed to load backend projects', e); - } - - return []; -} - -const checkSingleProjectSyncStatus = async (projectId: string) => { - const projectWorkspaces = await services.workspace.findByParentId(projectId); - const workspaceMetas = await database.find(models.workspaceMeta.type, { - parentId: { - $in: projectWorkspaces.map(w => w._id), - }, - }); - return workspaceMetas.some(item => item.hasUncommittedChanges || item.hasUnpushedChanges); -}; - -const CheckAllProjectSyncStatus = async (projects: Project[]) => { - const taskList = projects.map(project => checkSingleProjectSyncStatus(project._id)); - const res = await Promise.all(taskList); - const obj: Record = {}; - projects.forEach((project, index) => { - obj[project._id] = res[index]; - }); - return obj; -}; - -export async function clientLoader({ params }: LoaderFunctionArgs) { - const { organizationId, projectId } = params; - invariant(organizationId, 'Organization ID is required'); - const { id: sessionId } = await services.userSession.getOrCreate(); - - if (!projectId) { - return { - localFiles: [], - allFilesCount: 0, - documentsCount: 0, - environmentsCount: 0, - collectionsCount: 0, - mockServersCount: 0, - mcpClientsCount: 0, - projectsCount: 0, - activeProject: undefined, - projects: [], - }; - } - - if (!sessionId) { - await logout(); - throw redirect(href('/auth/login')); - } - - invariant(projectId, 'projectId parameter is required'); - - const project = await services.project.getById(projectId); - console.log('[project loader] Loading project:', project?.name, projectId); - const [localFiles, organizationProjects = []] = await Promise.all([ - getAllLocalFiles({ projectId }), - getProjectsWithGitRepositories({ organizationId }), - ]); - - const remoteFilesPromise = getAllRemoteFiles({ projectId, organizationId }); - - const projects = sortProjects(organizationProjects); - - const projectsSyncStatusPromise = CheckAllProjectSyncStatus(projects); - - const activeProjectGitRepository = - project && models.project.isGitProject(project) - ? await services.gitRepository.getById(project.gitRepositoryId || '') - : null; - - return { - localFiles, - remoteFilesPromise, - projects, - projectsCount: organizationProjects.length, - activeProject: project, - activeProjectGitRepository, - allFilesCount: localFiles.length, - environmentsCount: localFiles.filter(file => file.scope === 'environment').length, - documentsCount: localFiles.filter(file => file.scope === 'design').length, - collectionsCount: localFiles.filter(file => file.scope === 'collection').length, - mockServersCount: localFiles.filter(file => file.scope === 'mock-server').length, - mcpClientsCount: localFiles.filter(file => file.scope === 'mcp').length, - projectsSyncStatusPromise, - }; -} - -export function useProjectIndexLoaderData() { - return useRouteLoaderData('routes/organization.$organizationId.project.$projectId._index'); -} - const Component = () => { const { localFiles, activeProject, activeProjectGitRepository, projects, remoteFilesPromise } = - useLoaderData() as ProjectLoaderData; + useProjectLoaderData()!; const { organizationId, projectId } = useParams() as { organizationId: string; projectId: string; diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.list-workspaces.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.list-workspaces.tsx index e2e5a0750d..3dffc9aa0f 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.list-workspaces.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.list-workspaces.tsx @@ -4,6 +4,7 @@ import { parseApiSpec, type ParsedApiSpec } from '~/common/api-specs'; import { database } from '~/common/database'; import { scopeToLabelMap } from '~/common/get-workspace-label'; import { isNotNullOrUndefined } from '~/common/misc'; +import type { InsomniaFile } from '~/common/project'; import { descendingNumberSort } from '~/common/sorting'; import type { ApiSpec, GitRepository, MockServer, Project, WorkspaceMeta } from '~/insomnia-data'; import { services } from '~/insomnia-data'; @@ -13,7 +14,6 @@ import { invariant } from '~/utils/invariant'; import { createFetcherLoadHook } from '~/utils/router'; import type { Route } from './+types/organization.$organizationId.project.$projectId.list-workspaces'; -import { type InsomniaFile } from './organization.$organizationId.project.$projectId._index'; async function getAllLocalFiles({ projectId }: { projectId: string }) { const projectWorkspaces = await services.workspace.findByParentId(projectId); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx index 23e01b9960..8f1ba88e86 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.tsx @@ -37,11 +37,11 @@ import { useParams, useSearchParams, } from 'react-router'; -import { useLocalStorage } from 'react-use'; +import * as reactUse from 'react-use'; import { DEFAULT_SIDEBAR_SIZE, getProductName, SORT_ORDERS, type SortOrder, sortOrderName } from '~/common/constants'; import { type ChangeBufferEvent } from '~/common/database'; -import { generateId, isNotNullOrUndefined } from '~/common/misc'; +import { generateId } from '~/common/misc'; import type { PlatformKeyCombinations } from '~/common/settings'; import type { Environment, @@ -76,24 +76,19 @@ import { DocumentTab } from '~/ui/components/document-tab'; import { RequestActionsDropdown } from '~/ui/components/dropdowns/request-actions-dropdown'; import { RequestGroupActionsDropdown } from '~/ui/components/dropdowns/request-group-actions-dropdown'; import { WorkspaceDropdown } from '~/ui/components/dropdowns/workspace-dropdown'; -import { WorkspaceSyncDropdown } from '~/ui/components/dropdowns/workspace-sync-dropdown'; import { EditableInput } from '~/ui/components/editable-input'; -import { EnvironmentPicker } from '~/ui/components/environment-picker'; import { ErrorBoundary } from '~/ui/components/error-boundary'; import { Icon } from '~/ui/components/icon'; import { useDocBodyKeyboardShortcuts } from '~/ui/components/keydown-binder'; import { McpPane } from '~/ui/components/mcp/mcp-pane'; import { showModal } from '~/ui/components/modals'; import { AskModal } from '~/ui/components/modals/ask-modal'; -import { CookiesModal } from '~/ui/components/modals/cookies-modal'; import { ErrorModal } from '~/ui/components/modals/error-modal'; import { GenerateCodeModal } from '~/ui/components/modals/generate-code-modal'; import { ImportModal } from '~/ui/components/modals/import-modal/import-modal'; import { PasteCurlModal } from '~/ui/components/modals/paste-curl-modal'; import { PromptModal } from '~/ui/components/modals/prompt-modal'; import { RequestSettingsModal } from '~/ui/components/modals/request-settings-modal'; -import { CertificatesModal } from '~/ui/components/modals/workspace-certificates-modal'; -import { WorkspaceEnvironmentsEditModal } from '~/ui/components/modals/workspace-environments-edit-modal'; import { GrpcRequestPane } from '~/ui/components/panes/grpc-request-pane'; import { GrpcResponsePane } from '~/ui/components/panes/grpc-response-pane'; import { PlaceholderRequestPane } from '~/ui/components/panes/placeholder-request-pane'; @@ -271,7 +266,7 @@ const Debug = () => { panel?: string; }; - const [filter, setFilter] = useLocalStorage(`${workspaceId}:collection-list-filter`); + const [filter, setFilter] = reactUse.useLocalStorage(`${workspaceId}:collection-list-filter`); const collection = useFilteredRequests(_collection, filter ?? ''); const isDesignWorkspace = models.workspace.isDesign(activeWorkspace); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx index 8a7a16f8a0..4f77bb0b31 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project._index.tsx @@ -3,11 +3,11 @@ import type { LoaderFunctionArgs } from 'react-router'; import { href, redirect, useParams } from 'react-router'; import { logout } from '~/account/session'; +import { getProjectsWithGitRepositories } from '~/common/project'; import type { GitRepository, Project } from '~/insomnia-data'; import { services } from '~/insomnia-data'; import { sortProjects } from '~/models/helpers/project'; import { isScratchpadOrganizationId } from '~/models/organization'; -import { getProjectsWithGitRepositories } from '~/routes/organization.$organizationId.project.$projectId._index'; import { useStorageRulesLoaderFetcher } from '~/routes/organization.$organizationId.storage-rules'; import { ErrorBoundary } from '~/ui/components/error-boundary'; import { NoProjectView } from '~/ui/components/panes/no-project-view'; diff --git a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx index 8e825a9c02..d8d9f43f6f 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx @@ -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'; diff --git a/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx new file mode 100644 index 0000000000..034252955f --- /dev/null +++ b/packages/insomnia/src/ui/components/dropdowns/sidebar-workspace-dropdown.tsx @@ -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 ( + + + + + + 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 => ( + +
+ {section.name} +
+ + {item => ( + + + {item.name} + {item.hint && } + + )} + +
+ )} +
+
+
+ + {isDuplicateModalOpen && ( + setIsDuplicateModalOpen(false)} /> + )} + {isImportModalOpen && ( + setIsImportModalOpen(false)} + from={{ type: 'file' }} + projectName={projectName} + workspaceName={workspaceName} + organizationId={organizationId} + defaultProjectId={projectId} + defaultWorkspaceId={workspaceId} + /> + )} + {isExportModalOpen && ( + setIsExportModalOpen(false)} /> + )} + {isSettingsModalOpen && ( + setIsSettingsModalOpen(false)} /> + )} + {isDeleteModalOpen && ( + 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" + > + 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)" + > + + {({ close }) => ( +
+
+ Delete {getWorkspaceLabel(workspace).singular} + +
+ + +
+

+ This will permanently delete the{' '} + {workspaceName}{' '} + {getWorkspaceLabel(workspace).singular} +

+ {models.project.isRemoteProject(project) && ( + + +
+ +
+ Remove Local Copy +

The project will still exist on the Cloud.

+
+
+ +
+ Delete Permanently +

+ The project will be deleted everywhere. You cannot undo this action. +

+
+
+
+
+ )} +
+ {deleteWorkspaceFetcher.data?.error && ( +

{deleteWorkspaceFetcher.data.error}

+ )} +
+ +
+
+
+ )} +
+
+
+ )} + {isPasteCurlModalOpen && ( + { + newRequestFetcher.submit({ + organizationId, + projectId, + workspaceId, + requestType: 'From Curl', + parentId: workspaceId, + req, + }); + }} + onHide={() => setPasteCurlModalOpen(false)} + /> + )} +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/panes/scratchpad-tutorial-pane.tsx b/packages/insomnia/src/ui/components/panes/scratchpad-tutorial-pane.tsx index 0e7445cecb..95e873bb80 100644 --- a/packages/insomnia/src/ui/components/panes/scratchpad-tutorial-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/scratchpad-tutorial-pane.tsx @@ -1,13 +1,13 @@ import { useMemo, useState } from 'react'; import { Button, GridList, GridListItem } from 'react-aria-components'; import { href, useNavigate, useParams } from 'react-router'; -import { useLocalStorage } from 'react-use'; +import * as reactUse from 'react-use'; import { scratchPadTutorialList } from '~/routes/organization.$organizationId.project.$projectId.tutorial.$panel'; import { Icon } from '~/ui/components/icon'; export const ScratchPadTutorialPanel = () => { - const [signUpTipDismissedState, setSignUpTipDismissedState] = useLocalStorage<{ + const [signUpTipDismissedState, setSignUpTipDismissedState] = reactUse.useLocalStorage<{ dismissed: boolean; dismissedAt: number; }>('scratchpad-sign-up-tip-dismissed', { dismissed: false, dismissedAt: 0 }); @@ -21,7 +21,6 @@ export const ScratchPadTutorialPanel = () => { const { organizationId, projectId, - workspaceId, panel = 'all', } = useParams() as { organizationId: string; diff --git a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx index 55f714228c..8822c9054e 100644 --- a/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx +++ b/packages/insomnia/src/ui/components/sidebar/project-navigation-sidebar/project-navigation-sidebar.tsx @@ -6,6 +6,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 ? (