mirror of
https://github.com/Kong/insomnia.git
synced 2026-04-17 20:58:23 -04:00
refine loader behavior
This commit is contained in:
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user