mirror of
https://github.com/Kong/insomnia.git
synced 2026-07-30 09:16:44 -04:00
feat(Git Sync): Git projects can live anywhere on disk (#10155)
* tech_design * tech-doc * add directory to git repo model * tech doc * tech doc * use directory picker to select an existing repo to clone from/to * tech doc * Open git repo * tech doc * Implement Git project local storage features and add e2e tests * tech doc * Implement folder opening as Git projects with user trust confirmation * Add Git credential selection to project creation form and enhance repo file watcher for directory availability * tech doc * refactor: clean up code and remove references to GIT_LOCAL_REPOS_DESIGN.md * fix: handle optional author name in Git credential display * feat: enhance Git credential handling for local repositories * feat: enhance Git project folder handling and improve test descriptions * fix: update Git project mode button copy Rename 'Clone from URL' to 'Clone from Remote' and 'Open existing folder' to 'Open local folder'. Update the smoke test selector and a stale comment accordingly. * fix: align control heights and styling in Git clone form Standardize the credential select, author email select, and clone location box to match the repository/branch comboboxes: shared --line-height-xs height, consistent label spacing, and input-sized value text and padding. * feat: show clone target path and remember last clone folder Default the clone parent directory to the folder the user last cloned into, and render the resulting target path middle-truncated with a full path tooltip via a new MiddleTruncate component. * fix: reorder and align Open local folder input layout Move the helper text directly below the Folder label, and align the folder box and Choose folder button to the shared control height with middle-truncated path display. * feat: warn when opening a folder already used by a project Add a git.checkGitRepoDirectory IPC that resolves the project adopting a folder. The Open local folder flow checks at folder-pick time and shows a red, no-background warning below the input offering to open the existing project, and blocks continuing while the warning is present. * fix: top-align empty organization view and scroll the full page Replace the vertically centered grid with a top-aligned, page-scrolling layout so the new project form no longer jumps when switching project types and the scrollbar spans the whole pane. * fix: align project modal to top and match folder description color Top-align the project modal overlay so it no longer jumps as the form height changes, and drop the dimmer color override on the Open local folder helper text so it matches the repository URL field description. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: always use getRepoBaseDir * fix comment * delete duplicate code * fix: test * test: add Git repository relocation tests * fix: ensure selection change handler converts key to string --------- Co-authored-by: Pavlos Koutoglou <pkoutoglou@gmail.com> Co-authored-by: Curry Yang <163384738+CurryYangxx@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Curry Yang <1019yanglu@gmail.com>
This commit is contained in:
@@ -18,6 +18,10 @@ export async function getAllByCredentialId(credentialsId: string) {
|
||||
return db.find<GitRepository>(type, { credentialsId });
|
||||
}
|
||||
|
||||
export async function getByDirectory(directory: string) {
|
||||
return db.findOne<GitRepository>(type, { directory });
|
||||
}
|
||||
|
||||
export function update(repo: GitRepository, patch: Partial<GitRepository>) {
|
||||
return db.docUpdate<GitRepository>(repo, patch);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export function init(): BaseGitRepository {
|
||||
hasUnpushedChanges: false,
|
||||
uriNeedsMigration: true,
|
||||
repoMigrationVersion: 0,
|
||||
directory: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +70,16 @@ export interface BaseGitRepository {
|
||||
* for version-rollback scenarios.
|
||||
*/
|
||||
repoMigrationVersion: number;
|
||||
/**
|
||||
* Absolute path to a user-chosen location on the local filesystem where this
|
||||
* repository's working tree and .git directory live.
|
||||
*
|
||||
* `null` (the default) means the repository is stored in the app-managed
|
||||
* location: `{INSOMNIA_DATA_PATH || userData}/version-control/git/{_id}`.
|
||||
* Insomnia owns that managed folder. When `directory` is set, the user owns
|
||||
* the folder and Insomnia must not delete it on project removal.
|
||||
*/
|
||||
directory: string | null;
|
||||
}
|
||||
|
||||
export const isGitRepository = (model: Pick<BaseModel, 'type'>): model is GitRepository => model.type === type;
|
||||
|
||||
@@ -72,7 +72,9 @@ export class ProjectPage extends BasePage {
|
||||
}
|
||||
|
||||
const breadcrumbLink = this.page.getByTestId('workspace-breadcrumb-level-0').getByRole('link');
|
||||
await ((await breadcrumbLink.isVisible()) ? breadcrumbLink.click() : this.sidebar.projectRow(projectName).click());
|
||||
await ((await breadcrumbLink.isVisible())
|
||||
? breadcrumbLink.click()
|
||||
: this.sidebar.projectRow(projectName).click());
|
||||
|
||||
try {
|
||||
await this.page.waitForURL(this.projectDashboardUrl, { timeout: 5000, waitUntil: 'commit' });
|
||||
@@ -103,9 +105,7 @@ export class ProjectPage extends BasePage {
|
||||
async createCollection(name = 'My Collection'): Promise<void> {
|
||||
await this.selectCreateInProjectType('Collection');
|
||||
await this.page.getByRole('dialog').waitFor({ state: 'visible' });
|
||||
const nameInput = this.page
|
||||
.getByRole('dialog')
|
||||
.getByPlaceholder('Enter a name for your Request Collection');
|
||||
const nameInput = this.page.getByRole('dialog').getByPlaceholder('Enter a name for your Request Collection');
|
||||
await nameInput.waitFor({ state: 'visible' });
|
||||
await nameInput.fill(name);
|
||||
await this.page.getByRole('dialog').getByRole('button', { name: 'Create' }).click();
|
||||
@@ -151,6 +151,57 @@ export class ProjectPage extends BasePage {
|
||||
await this.page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects an existing local folder in the Git project form without opening it.
|
||||
* The native directory picker is mocked to return `folderPath`.
|
||||
*/
|
||||
async chooseGitProjectFolderForOpen(name: string, folderPath: string): Promise<void> {
|
||||
await mockOpenDialogForDirectory(this.app, folderPath);
|
||||
await this.page.getByRole('button', { name: 'Create new Project' }).click();
|
||||
await this.setProjectName(name);
|
||||
await this.selectStorageType('git');
|
||||
await this.page.getByRole('button', { name: 'Open local folder' }).click();
|
||||
await this.page.getByRole('button', { name: 'Choose folder' }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an existing local folder as a Git project (no clone). The native
|
||||
* directory picker is mocked to return `folderPath`. If the folder isn't a git
|
||||
* repo, the app runs `git init`.
|
||||
*/
|
||||
async openGitProjectFromFolder(name: string, folderPath: string): Promise<void> {
|
||||
await this.chooseGitProjectFolderForOpen(name, folderPath);
|
||||
await this.page.getByRole('button', { name: 'Open', exact: true }).click();
|
||||
// Confirm the "Do you trust this folder?" dialog before the folder is opened/initialized.
|
||||
await this.page.getByRole('button', { name: 'Open folder' }).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones a repo into a user-chosen parent folder (the picker is mocked to
|
||||
* return `parentFolderPath`). The repo is cloned into
|
||||
* `<parentFolderPath>/<repo-name>`. Requires the git test server + credential.
|
||||
*/
|
||||
async cloneGitProjectIntoFolder(name: string, parentFolderPath: string): Promise<void> {
|
||||
await mockOpenDialogForDirectory(this.app, parentFolderPath);
|
||||
await this.sidebar.clickNewProject();
|
||||
await this.page.getByRole('textbox', { name: 'Project name' }).click();
|
||||
await this.page.getByRole('textbox', { name: 'Project name' }).press('ControlOrMeta+a');
|
||||
await this.page.getByRole('textbox', { name: 'Project name' }).fill(name);
|
||||
await this.page.getByText('Git Sync').click();
|
||||
await this.page.getByRole('button', { name: 'Git Credentials Authorized as' }).click();
|
||||
await this.page.getByRole('option', { name: 'Custom Git Credential' }).click();
|
||||
await this.page.getByRole('textbox', { name: 'Repository URL' }).click();
|
||||
// Use the reachable git test server URL so remote branches can be listed.
|
||||
// deriveRepoName() still yields "git-server" from this URL.
|
||||
await this.page.getByRole('textbox', { name: 'Repository URL' }).fill('http://localhost:4010/git/git-server.git');
|
||||
await this.page.getByRole('button', { name: 'Show suggestions Branch' }).click();
|
||||
await this.page.getByRole('option', { name: 'master' }).click();
|
||||
// Pick the custom clone destination before scanning.
|
||||
await this.page.getByRole('button', { name: 'Choose folder' }).click();
|
||||
await this.page.getByRole('button', { name: 'Scan for files' }).click();
|
||||
await this.page.getByRole('button', { name: 'Create Blank Project' }).click();
|
||||
}
|
||||
|
||||
async createGitSyncProject(name = 'My Git Project'): Promise<void> {
|
||||
await this.sidebar.clickNewProject();
|
||||
await this.page.getByRole('textbox', { name: 'Project name' }).click();
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import type { InsomniaApp } from '../../playwright/pages';
|
||||
import { test } from '../../playwright/test';
|
||||
|
||||
// Verifies repositories can live in user-chosen folders on disk:
|
||||
// - opening an existing/plain folder as a Git project (runs `git init` if needed)
|
||||
// - cloning into a user-chosen destination folder
|
||||
|
||||
const makeTempDir = (prefix: string) => fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
|
||||
test.describe('Git repositories in user-chosen folders', () => {
|
||||
test.slow();
|
||||
|
||||
test('opens a plain local folder as a Git project and initializes git', async ({ insomnia, page }) => {
|
||||
const folder = makeTempDir('insomnia-open-folder-');
|
||||
try {
|
||||
// The folder is not a git repo yet — no .git present.
|
||||
expect.soft(fs.existsSync(path.join(folder, '.git'))).toBe(false);
|
||||
|
||||
await insomnia.projectPage.openGitProjectFromFolder('Opened Folder Project', folder);
|
||||
|
||||
// `git init` must have run inside the chosen folder.
|
||||
await expect.poll(() => fs.existsSync(path.join(folder, '.git')), { timeout: 30_000 }).toBe(true);
|
||||
|
||||
// The project landed — no error banner.
|
||||
await expect.soft(page.getByText('Opened Folder Project')).toBeVisible();
|
||||
} finally {
|
||||
fs.rmSync(folder, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('blocks opening the same folder twice (collision guard)', async ({ insomnia, page }) => {
|
||||
const folder = makeTempDir('insomnia-open-collision-');
|
||||
try {
|
||||
await insomnia.projectPage.openGitProjectFromFolder('First Adoption', folder);
|
||||
await expect.poll(() => fs.existsSync(path.join(folder, '.git')), { timeout: 30_000 }).toBe(true);
|
||||
|
||||
// Back to the project dashboard, then try to adopt the same folder again.
|
||||
await insomnia.projectPage.chooseGitProjectFolderForOpen('Second Adoption', folder);
|
||||
|
||||
// The UI blocks before submit and surfaces the already-connected folder.
|
||||
await expect.soft(page.getByText(/already connected to this folder/i)).toBeVisible();
|
||||
await expect.soft(page.getByRole('button', { name: 'Open', exact: true })).toBeDisabled();
|
||||
} finally {
|
||||
fs.rmSync(folder, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Git clone into a user-chosen folder', () => {
|
||||
test.slow();
|
||||
|
||||
test.beforeEach(async ({ insomnia, request }) => {
|
||||
await request.post('http://127.0.0.1:4010/v1/test-utils/git/setup');
|
||||
await addAccessTokenGitCredential(insomnia);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await request.delete('http://127.0.0.1:4010/v1/test-utils/git/setup');
|
||||
});
|
||||
|
||||
test('clones into <chosen-parent>/<repo-name>', async ({ insomnia }) => {
|
||||
const parent = makeTempDir('insomnia-clone-dest-');
|
||||
try {
|
||||
await insomnia.projectPage.cloneGitProjectIntoFolder('Cloned Into Folder', parent);
|
||||
|
||||
// The repo URL is "git-server.git" → derived repo name "git-server".
|
||||
await expect.poll(() => fs.existsSync(path.join(parent, 'git-server', '.git')), { timeout: 60_000 }).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(parent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function addAccessTokenGitCredential(insomnia: InsomniaApp) {
|
||||
await insomnia.statusbar.openPreferences();
|
||||
await insomnia.preferencesPage.switchToPreferenceTab('Credentials');
|
||||
await insomnia.preferencesPage.credentialsTab.addAccessTokenGitCredential();
|
||||
await expect.soft(insomnia.page.getByRole('row', { name: 'Custom Git Credential' })).toBeVisible();
|
||||
await insomnia.preferencesPage.closePreferences();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { expect } from '@playwright/test';
|
||||
|
||||
import type { InsomniaApp } from '../../playwright/pages';
|
||||
import { test } from '../../playwright/test';
|
||||
import { mockOpenDialogForDirectory } from '../../playwright/utils';
|
||||
|
||||
// deriveRepoName('http://localhost:4010/git/git-server.git') === 'git-server'
|
||||
const DERIVED_REPO_NAME = 'git-server';
|
||||
const GIT_PROJECT_NAME = 'Relocation Test Project';
|
||||
|
||||
const makeTempDir = (prefix: string) => fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
|
||||
test.describe('Git repository relocation', () => {
|
||||
test.slow();
|
||||
|
||||
test.beforeEach(async ({ insomnia, request }) => {
|
||||
await request.post('http://127.0.0.1:4010/v1/test-utils/git/setup');
|
||||
await addAccessTokenGitCredential(insomnia);
|
||||
await insomnia.projectPage.createGitSyncProject(GIT_PROJECT_NAME);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await request.delete('http://127.0.0.1:4010/v1/test-utils/git/setup');
|
||||
});
|
||||
|
||||
test('moves the repo to a new parent folder and updates the displayed path', async ({ insomnia, page }) => {
|
||||
// Ensure the project dashboard URL has settled before interacting with the sidebar.
|
||||
await insomnia.projectPage.waitForProjectDashboard();
|
||||
|
||||
const destParent = makeTempDir('insomnia-relocate-dest-');
|
||||
const expectedPath = path.join(destParent, DERIVED_REPO_NAME);
|
||||
try {
|
||||
await openProjectSettingsModal(insomnia, GIT_PROJECT_NAME);
|
||||
|
||||
await mockOpenDialogForDirectory(insomnia.app, destParent);
|
||||
await page.getByRole('button', { name: 'Move repository to another folder' }).click();
|
||||
|
||||
// Path display updates immediately from the action result (before the loader revalidates).
|
||||
await expect.soft(page.getByTitle(expectedPath)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The new directory must exist on disk (rename if source existed, mkdir otherwise).
|
||||
await expect.poll(() => fs.existsSync(expectedPath), { timeout: 30_000 }).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(destParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('shows an error when the destination folder already exists (collision guard)', async ({ insomnia, page }) => {
|
||||
await insomnia.projectPage.waitForProjectDashboard();
|
||||
|
||||
const destParent = makeTempDir('insomnia-relocate-collision-');
|
||||
// Pre-create the derived subdirectory so the collision guard fires.
|
||||
fs.mkdirSync(path.join(destParent, DERIVED_REPO_NAME));
|
||||
try {
|
||||
await openProjectSettingsModal(insomnia, GIT_PROJECT_NAME);
|
||||
|
||||
await mockOpenDialogForDirectory(insomnia.app, destParent);
|
||||
await page.getByRole('button', { name: 'Move repository to another folder' }).click();
|
||||
|
||||
// Error banner text matches relocateGitRepoAction's collision message.
|
||||
await expect.soft(page.getByText(/That folder already exists/i)).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
fs.rmSync(destParent, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function openProjectSettingsModal(insomnia: InsomniaApp, projectName: string): Promise<void> {
|
||||
await insomnia.navigationSidebar.selectProjectDropdownOption({
|
||||
actionName: 'Settings',
|
||||
projectName,
|
||||
});
|
||||
await insomnia.page.getByRole('dialog', { name: 'Create or update dialog' }).waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
async function addAccessTokenGitCredential(insomnia: InsomniaApp): Promise<void> {
|
||||
await insomnia.statusbar.openPreferences();
|
||||
await insomnia.preferencesPage.switchToPreferenceTab('Credentials');
|
||||
await insomnia.preferencesPage.credentialsTab.addAccessTokenGitCredential();
|
||||
await expect.soft(insomnia.page.getByRole('row', { name: 'Custom Git Credential' })).toBeVisible();
|
||||
await insomnia.preferencesPage.closePreferences();
|
||||
}
|
||||
@@ -58,6 +58,15 @@ const config = {
|
||||
NSRequiresAquaSystemAppearance: false,
|
||||
NSLocalNetworkUsageDescription:
|
||||
'Insomnia needs permission to connect to local APIs and development servers such as localhost, 127.0.0.1, or other LAN hosts.',
|
||||
// Allow Finder "Open With → Insomnia" on folders so they can be opened as Git projects.
|
||||
CFBundleDocumentTypes: [
|
||||
{
|
||||
CFBundleTypeName: 'Folder',
|
||||
CFBundleTypeRole: 'Viewer',
|
||||
LSHandlerRank: 'Alternate',
|
||||
LSItemContentTypes: ['public.folder'],
|
||||
},
|
||||
],
|
||||
},
|
||||
// If this step fails its possible apple has new license terms which need to be accepted by logging into https://developer.apple.com/account
|
||||
notarize: true,
|
||||
|
||||
@@ -229,17 +229,37 @@ app.on('activate', (_error, hasVisibleWindows) => {
|
||||
}
|
||||
});
|
||||
|
||||
// When a folder path is opened from the OS (CLI arg, Finder "Open With", etc.),
|
||||
// normalise it into the open-folder deep link so the renderer has a single,
|
||||
// well-formed entry point. Returns null when the path isn't an existing folder.
|
||||
const toOpenFolderDeepLink = async (rawPath: string): Promise<string | null> => {
|
||||
try {
|
||||
if (!rawPath || !path.isAbsolute(rawPath)) {
|
||||
return null;
|
||||
}
|
||||
const stats = await fs.stat(rawPath);
|
||||
if (!stats.isDirectory()) {
|
||||
return null;
|
||||
}
|
||||
return `insomnia://app/open-folder?path=${encodeURIComponent(rawPath)}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const _launchApp = async () => {
|
||||
await _trackStats();
|
||||
let window: BrowserWindow;
|
||||
// Handle URLs sent via command line args
|
||||
ipcMainOnce('halfSecondAfterAppStart', () => {
|
||||
ipcMainOnce('halfSecondAfterAppStart', async () => {
|
||||
console.log('[main] Window ready, handling command line arguments', process.argv);
|
||||
const args = process.argv.slice(1).filter(a => a !== '.');
|
||||
console.log('[main] Check args and create windows', args);
|
||||
if (args.length) {
|
||||
window = windowUtils.createWindowsAndReturnMain();
|
||||
window.webContents.send('shell:open', args.join(','));
|
||||
// A folder path (e.g. `insomnia /path/to/repo`) opens it as a Git project.
|
||||
const folderDeepLink = await toOpenFolderDeepLink(args[args.length - 1]);
|
||||
window.webContents.send('shell:open', folderDeepLink || args.join(','));
|
||||
}
|
||||
});
|
||||
// Disable deep linking in playwright e2e tests in order to run multiple tests in parallel
|
||||
@@ -251,7 +271,7 @@ const _launchApp = async () => {
|
||||
app.quit();
|
||||
} else {
|
||||
// Called when second instance launched with args (Windows/Linux)
|
||||
app.on('second-instance', (_1, args) => {
|
||||
app.on('second-instance', async (_1, args) => {
|
||||
console.log('[main] Second instance listener received:', args.join('||'));
|
||||
window = windowUtils.createWindowsAndReturnMain();
|
||||
if (window) {
|
||||
@@ -262,7 +282,8 @@ const _launchApp = async () => {
|
||||
}
|
||||
const lastArg = args.slice(-1).join(',');
|
||||
console.log('[main] Open Deep Link URL sent from second instance', lastArg);
|
||||
window.webContents.send('shell:open', lastArg);
|
||||
const folderDeepLink = await toOpenFolderDeepLink(lastArg);
|
||||
window.webContents.send('shell:open', folderDeepLink || lastArg);
|
||||
});
|
||||
window = windowUtils.createWindowsAndReturnMain();
|
||||
|
||||
@@ -283,6 +304,15 @@ const _launchApp = async () => {
|
||||
app.on('open-url', (_event, url) => {
|
||||
openDeepLinkUrl(url);
|
||||
});
|
||||
// macOS Finder "Open With" → Insomnia for a folder (declared as a folder
|
||||
// document type in electron-builder.config.js). Only folders are adopted.
|
||||
app.on('open-file', async (event, filePath) => {
|
||||
event.preventDefault();
|
||||
const folderDeepLink = await toOpenFolderDeepLink(filePath);
|
||||
if (folderDeepLink) {
|
||||
openDeepLinkUrl(folderDeepLink);
|
||||
}
|
||||
});
|
||||
ipcMainOn('openDeepLink', (_event, url) => {
|
||||
openDeepLinkUrl(url);
|
||||
});
|
||||
|
||||
@@ -213,6 +213,10 @@ const git: GitServiceAPI = {
|
||||
gitChangesLoader: options => invokeWithNormalizedError('git.gitChangesLoader', options),
|
||||
canPushLoader: options => invokeWithNormalizedError('git.canPushLoader', options),
|
||||
cloneGitRepo: options => invokeWithNormalizedError('git.cloneGitRepo', options),
|
||||
openGitRepo: options => invokeWithNormalizedError('git.openGitRepo', options),
|
||||
checkGitRepoDirectory: options => invokeWithNormalizedError('git.checkGitRepoDirectory', options),
|
||||
cleanupGitRepoStorage: options => invokeWithNormalizedError('git.cleanupGitRepoStorage', options),
|
||||
relocateGitRepo: options => invokeWithNormalizedError('git.relocateGitRepo', options),
|
||||
initGitRepoClone: options => invokeWithNormalizedError('git.initGitRepoClone', options),
|
||||
updateGitRepo: options => invokeWithNormalizedError('git.updateGitRepo', options),
|
||||
resetGitRepo: options => invokeWithNormalizedError('git.resetGitRepo', options),
|
||||
|
||||
@@ -387,32 +387,80 @@ async function assertBranchOnOrigin(context: string): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a file system client for Git operations
|
||||
* Returns different clients based on whether we're working with a workspace or project
|
||||
* Resolves the absolute base directory where a Git repository's working tree and
|
||||
* .git directory live on disk.
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param workspaceId - Optional workspace ID (if provided, uses workspace-specific client)
|
||||
* @param gitRepositoryId - The Git repository ID
|
||||
* @returns File system client configured for the appropriate context
|
||||
* - When the repository has a user-chosen `directory`, that path is used as-is
|
||||
* (the user owns it; Insomnia must not delete it on project removal).
|
||||
* - Otherwise the repository lives in the app-managed location
|
||||
* `{INSOMNIA_DATA_PATH || userData}/version-control/git/{id}`.
|
||||
*
|
||||
* This is the single source of truth for repo paths. Callers that already have
|
||||
* the GitRepository document should pass `directory` to avoid a DB lookup;
|
||||
* otherwise the directory is resolved from the database by id.
|
||||
*/
|
||||
function getGitBaseDir(gitRepositoryId: string): string {
|
||||
async function getRepoBaseDir(gitRepositoryId: string, directory?: string | null): Promise<string> {
|
||||
let dir = directory;
|
||||
if (dir === undefined) {
|
||||
const repo = await services.gitRepository.getById(gitRepositoryId);
|
||||
dir = repo?.directory ?? null;
|
||||
}
|
||||
|
||||
if (dir) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
return path.join(
|
||||
process.env['INSOMNIA_DATA_PATH'] || app.getPath('userData'),
|
||||
`version-control/git/${gitRepositoryId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a repository's on-disk folder, but ONLY when Insomnia owns it.
|
||||
*
|
||||
* Insomnia owns the app-managed location (`directory === null`). A user-chosen
|
||||
* `directory` belongs to the user and must never be deleted when the project is
|
||||
* removed. Failures are logged, not thrown — losing a project record should not be blocked by a stale folder.
|
||||
*/
|
||||
async function deleteManagedRepoFolderIfOwned(repo: Pick<GitRepository, '_id' | 'directory'>) {
|
||||
if (repo.directory) {
|
||||
// User-owned folder — leave it on disk.
|
||||
return;
|
||||
}
|
||||
|
||||
const baseDir = await getRepoBaseDir(repo._id, null);
|
||||
try {
|
||||
await fs.promises.rm(baseDir, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.warn('[git] Failed to remove managed repo folder', baseDir, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a file system client for Git operations
|
||||
* Returns different clients based on whether we're working with a workspace or project
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param workspaceId - Optional workspace ID (if provided, uses workspace-specific client)
|
||||
* @param gitRepositoryId - The Git repository ID
|
||||
* @param directory - Optional user-chosen repo directory (resolved from DB when omitted)
|
||||
* @returns File system client configured for the appropriate context
|
||||
*/
|
||||
|
||||
async function getGitFSClient({
|
||||
projectId,
|
||||
workspaceId,
|
||||
gitRepositoryId,
|
||||
directory,
|
||||
}: {
|
||||
projectId: string;
|
||||
workspaceId?: string;
|
||||
gitRepositoryId: string;
|
||||
directory?: string | null;
|
||||
}) {
|
||||
// Base directory where Git data is stored
|
||||
const baseDir = getGitBaseDir(gitRepositoryId);
|
||||
const baseDir = await getRepoBaseDir(gitRepositoryId, directory);
|
||||
|
||||
// Workspace FS Client - used when working with a specific workspace
|
||||
if (workspaceId) {
|
||||
@@ -546,10 +594,39 @@ export async function loadGitRepository({ projectId, workspaceId }: { projectId:
|
||||
try {
|
||||
const gitRepository = await getGitRepository({ workspaceId, projectId });
|
||||
|
||||
const baseDir = getGitBaseDir(gitRepository._id);
|
||||
const baseDir = await getRepoBaseDir(gitRepository._id, gitRepository.directory);
|
||||
|
||||
// For a user-owned folder, detect that it has been moved/renamed/deleted (or
|
||||
// its drive unmounted) BEFORE creating the FS client — `fsClient` auto-creates
|
||||
// its base dir, which would silently resurrect a deleted folder as empty and
|
||||
// let the watcher wipe the database. We surface an "unavailable" state and let
|
||||
// the user re-locate or remove the project; we never auto-remove it.
|
||||
if (gitRepository.directory) {
|
||||
let isAvailable = false;
|
||||
try {
|
||||
isAvailable = (await fs.promises.stat(gitRepository.directory)).isDirectory();
|
||||
} catch {
|
||||
isAvailable = false;
|
||||
}
|
||||
if (!isAvailable) {
|
||||
return {
|
||||
errors: [
|
||||
`Repository folder not found at "${gitRepository.directory}". It may have been moved, renamed, or deleted, or its drive may not be mounted.`,
|
||||
],
|
||||
repositoryUnavailable: true,
|
||||
directory: gitRepository.directory,
|
||||
gitRepository,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const bufferId = await database.bufferChanges();
|
||||
const fsClient = await getGitFSClient({ gitRepositoryId: gitRepository._id, projectId, workspaceId });
|
||||
const fsClient = await getGitFSClient({
|
||||
gitRepositoryId: gitRepository._id,
|
||||
projectId,
|
||||
workspaceId,
|
||||
directory: gitRepository.directory,
|
||||
});
|
||||
|
||||
if (GitVCS.isInitializedForRepo(gitRepository._id) && !gitRepository.needsFullClone) {
|
||||
let legacyInsomniaWorkspace;
|
||||
@@ -1115,6 +1192,7 @@ export const cloneGitRepoAction = async ({
|
||||
uri,
|
||||
ref,
|
||||
selectedAuthorEmail,
|
||||
directory,
|
||||
}: {
|
||||
organizationId: string;
|
||||
projectId?: string;
|
||||
@@ -1124,6 +1202,11 @@ export const cloneGitRepoAction = async ({
|
||||
uri: string;
|
||||
ref?: string;
|
||||
selectedAuthorEmail?: string | null;
|
||||
/**
|
||||
* Optional absolute path to a user-chosen folder to clone into. When omitted,
|
||||
* the repository is stored in the app-managed location.
|
||||
*/
|
||||
directory?: string | null;
|
||||
}) => {
|
||||
try {
|
||||
const repoSettingsPatch: Partial<GitRepository> = {};
|
||||
@@ -1134,6 +1217,24 @@ export const cloneGitRepoAction = async ({
|
||||
repoSettingsPatch.selectedAuthorEmail = selectedAuthorEmail;
|
||||
}
|
||||
|
||||
// When a user-chosen clone location is provided, validate it and guard
|
||||
// against two projects targeting the same folder.
|
||||
if (directory) {
|
||||
if (!path.isAbsolute(directory)) {
|
||||
return { errors: ['Clone location must be an absolute path.'] };
|
||||
}
|
||||
const resolvedDirectory = path.resolve(directory);
|
||||
|
||||
const existing = await services.gitRepository.getByDirectory(resolvedDirectory);
|
||||
if (existing) {
|
||||
return {
|
||||
errors: [`A project is already connected to this folder: ${resolvedDirectory}`],
|
||||
};
|
||||
}
|
||||
|
||||
repoSettingsPatch.directory = resolvedDirectory;
|
||||
}
|
||||
|
||||
let provider = 'custom';
|
||||
if (credentialsId) {
|
||||
const credentials = await services.gitCredentials.getById(credentialsId);
|
||||
@@ -1227,8 +1328,13 @@ export const cloneGitRepoAction = async ({
|
||||
|
||||
const project = await getProject();
|
||||
|
||||
const fsClient = await getGitFSClient({ projectId: project._id, gitRepositoryId: gitRepository._id });
|
||||
const repoBaseDir = getGitBaseDir(gitRepository._id);
|
||||
const fsClient = await getGitFSClient({
|
||||
projectId: project._id,
|
||||
gitRepositoryId: gitRepository._id,
|
||||
directory: gitRepository.directory,
|
||||
});
|
||||
|
||||
const repoBaseDir = await getRepoBaseDir(gitRepository._id, gitRepository.directory);
|
||||
|
||||
if (gitRepository.needsFullClone) {
|
||||
await GitVCS.initFromClone({
|
||||
@@ -1266,7 +1372,7 @@ export const cloneGitRepoAction = async ({
|
||||
}
|
||||
|
||||
// Start watcher — it automatically imports all YAML files during creation
|
||||
const cloneBaseDir = getGitBaseDir(gitRepository._id);
|
||||
const cloneBaseDir = repoBaseDir;
|
||||
|
||||
// If the project already has a ruleset in the DB (e.g. cloud → git migration),
|
||||
// write it to disk now so its mtime is newer than the cloned file. This ensures
|
||||
@@ -1449,7 +1555,7 @@ export const cloneGitRepoAction = async ({
|
||||
workspaceId,
|
||||
gitRepositoryId: gitRepository._id,
|
||||
});
|
||||
const wsRepoBaseDir = getGitBaseDir(gitRepository._id);
|
||||
const wsRepoBaseDir = await getRepoBaseDir(gitRepository._id);
|
||||
|
||||
// Configure basic info
|
||||
if (gitRepository.needsFullClone) {
|
||||
@@ -1504,6 +1610,263 @@ export const cloneGitRepoAction = async ({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Open/adopt an existing local folder as a Git project.
|
||||
*
|
||||
* Unlike {@link cloneGitRepoAction} this performs no network clone — it points
|
||||
* Insomnia at a folder already on disk:
|
||||
* - If the folder is not yet a git repo, `GitVCS.init` runs `git init`.
|
||||
* - Existing Insomnia YAML is imported by the watcher; a repo with no Insomnia
|
||||
* data simply yields an empty project ready to populate.
|
||||
* - Two projects may not target the same folder.
|
||||
*/
|
||||
export const openGitRepoAction = async ({
|
||||
organizationId,
|
||||
name,
|
||||
directory,
|
||||
credentialsId = null,
|
||||
}: {
|
||||
organizationId: string;
|
||||
name?: string;
|
||||
directory: string;
|
||||
/**
|
||||
* Optional Git credentials to associate with the adopted repository so it can
|
||||
* fetch/push to its remote. Null when the user opens a local-only folder or
|
||||
* defers credentials to the project settings.
|
||||
*/
|
||||
credentialsId?: string | null;
|
||||
}) => {
|
||||
try {
|
||||
if (!directory || !path.isAbsolute(directory)) {
|
||||
return { errors: ['A valid absolute folder path is required.'] };
|
||||
}
|
||||
const resolvedDirectory = path.resolve(directory);
|
||||
|
||||
// Validate the folder exists, is a directory and is readable/writable.
|
||||
try {
|
||||
const stats = await fs.promises.stat(resolvedDirectory);
|
||||
if (!stats.isDirectory()) {
|
||||
return { errors: [`Not a folder: ${resolvedDirectory}`] };
|
||||
}
|
||||
await fs.promises.access(resolvedDirectory, fs.constants.R_OK | fs.constants.W_OK);
|
||||
} catch {
|
||||
return {
|
||||
errors: [`Folder is not accessible (check it exists and you have read/write permission): ${resolvedDirectory}`],
|
||||
};
|
||||
}
|
||||
|
||||
// Hard-block if another project already owns this folder.
|
||||
const existing = await services.gitRepository.getByDirectory(resolvedDirectory);
|
||||
if (existing) {
|
||||
return { errors: [`A project is already connected to this folder: ${resolvedDirectory}`] };
|
||||
}
|
||||
|
||||
const bufferId = await database.bufferChanges();
|
||||
|
||||
const gitRepository = await services.gitRepository.create({
|
||||
uri: '',
|
||||
credentialsId,
|
||||
needsFullClone: false,
|
||||
directory: resolvedDirectory,
|
||||
});
|
||||
|
||||
const project = await services.project.create({
|
||||
name: name || path.basename(resolvedDirectory) || 'New Git Project',
|
||||
parentId: organizationId,
|
||||
gitRepositoryId: models.project.toProtectedRepoId(gitRepository._id),
|
||||
});
|
||||
|
||||
const fsClient = await getGitFSClient({
|
||||
projectId: project._id,
|
||||
gitRepositoryId: gitRepository._id,
|
||||
directory: resolvedDirectory,
|
||||
});
|
||||
|
||||
// Opens the existing repo, or runs `git init` when there is no .git yet.
|
||||
await GitVCS.init({
|
||||
repoId: gitRepository._id,
|
||||
uri: '',
|
||||
directory: GIT_CLONE_DIR,
|
||||
fs: fsClient,
|
||||
gitDirectory: GIT_INTERNAL_DIR,
|
||||
credentialsId,
|
||||
});
|
||||
|
||||
await GitVCS.setAuthor();
|
||||
|
||||
// Adopt the folder's existing origin remote (if any) as the repo URI so
|
||||
// future fetch/push targets the right place.
|
||||
let originUri = '';
|
||||
try {
|
||||
const remotes = await GitVCS.listRemotes();
|
||||
originUri = remotes.find(remote => remote.remote === 'origin')?.url || '';
|
||||
} catch {
|
||||
// No remotes configured — a local-only repository.
|
||||
}
|
||||
if (originUri) {
|
||||
await services.gitRepository.update(gitRepository, { uri: parseGitToHttpsURL(originUri) });
|
||||
}
|
||||
|
||||
// Migrate a legacy `.insomnia/` directory if one is present on disk.
|
||||
const hasLegacyInsomniaDir = await containsLegacyInsomniaDir({ fsClient });
|
||||
if (hasLegacyInsomniaDir) {
|
||||
await migrateLegacyInsomniaFolderToFile({ projectId: project._id });
|
||||
}
|
||||
|
||||
// Start the watcher — it imports any existing Insomnia YAML from disk into NeDB.
|
||||
await repoFileWatcherRegistry.startWatcher(gitRepository._id, resolvedDirectory, project._id);
|
||||
|
||||
const updateRepository = await services.gitRepository.getById(gitRepository._id);
|
||||
invariant(updateRepository, 'Git Repository not found');
|
||||
|
||||
let currentBranch: string | null = null;
|
||||
try {
|
||||
currentBranch = await GitVCS.getCurrentBranch();
|
||||
} catch {
|
||||
// A freshly-initialised repo may not have an active branch yet.
|
||||
}
|
||||
await services.gitRepository.update(updateRepository, {
|
||||
cachedGitLastCommitTime: Date.now(),
|
||||
cachedGitRepositoryBranch: currentBranch,
|
||||
});
|
||||
|
||||
await database.flushChanges(bufferId);
|
||||
|
||||
trackAnalyticsEvent(AnalyticsEvent.vcsSyncComplete, {
|
||||
...vcsEventProperties('git', 'clone'),
|
||||
providerName: 'custom',
|
||||
repoId: gitRepository._id,
|
||||
});
|
||||
|
||||
return { organizationId, projectId: project._id };
|
||||
} catch (e) {
|
||||
return { errors: [e instanceof Error ? e.message : String(e)] };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Release a Git repository's runtime/disk resources when its project is being
|
||||
* deleted (called from the renderer delete flow). Stops the file watcher and
|
||||
* deletes the on-disk folder only when Insomnia owns it — user-chosen folders
|
||||
* are left untouched.
|
||||
*
|
||||
* The DB document removal stays in the renderer action so it participates in the
|
||||
* same buffered change-set; this only handles the main-process-only concerns.
|
||||
*/
|
||||
export const cleanupGitRepoStorageAction = async ({ gitRepositoryId }: { gitRepositoryId: string }) => {
|
||||
const repo = await services.gitRepository.getById(gitRepositoryId);
|
||||
repoFileWatcherRegistry.stopWatcher(gitRepositoryId);
|
||||
clearConflictSuppression(gitRepositoryId);
|
||||
if (repo) {
|
||||
await deleteManagedRepoFolderIfOwned(repo);
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
/**
|
||||
* Move a Git project's on-disk repository to a user-chosen folder and record the
|
||||
* new location on `GitRepository.directory`.
|
||||
*
|
||||
* The whole repository (working tree + `.git`) is moved, so history and
|
||||
* uncommitted changes are preserved. If the previous location was the managed
|
||||
* folder it is left empty by the move (rename) or removed (cross-device copy).
|
||||
*/
|
||||
export const relocateGitRepoAction = async ({
|
||||
gitRepositoryId,
|
||||
projectId,
|
||||
newDirectory,
|
||||
}: {
|
||||
gitRepositoryId: string;
|
||||
projectId: string;
|
||||
newDirectory: string;
|
||||
}) => {
|
||||
if (!newDirectory || !path.isAbsolute(newDirectory)) {
|
||||
return { errors: ['A valid absolute folder path is required.'] };
|
||||
}
|
||||
const targetDir = path.resolve(newDirectory);
|
||||
|
||||
const repo = await services.gitRepository.getById(gitRepositoryId);
|
||||
invariant(repo, 'Git Repository not found');
|
||||
|
||||
const currentBaseDir = await getRepoBaseDir(repo._id, repo.directory);
|
||||
if (path.resolve(currentBaseDir) === targetDir) {
|
||||
return { errors: ['The repository is already in that folder.'] };
|
||||
}
|
||||
|
||||
// Hard-block if another project already owns the target.
|
||||
const existing = await services.gitRepository.getByDirectory(targetDir);
|
||||
if (existing && existing._id !== repo._id) {
|
||||
return { errors: [`A project is already connected to this folder: ${targetDir}`] };
|
||||
}
|
||||
|
||||
// Refuse to move onto an existing path — never clobber user data.
|
||||
try {
|
||||
await fs.promises.stat(targetDir);
|
||||
return { errors: [`That folder already exists: ${targetDir}. Choose a folder that does not exist yet.`] };
|
||||
} catch {
|
||||
// Good — the destination does not exist.
|
||||
}
|
||||
|
||||
// The destination's parent must exist and be writable.
|
||||
try {
|
||||
await fs.promises.access(path.dirname(targetDir), fs.constants.W_OK);
|
||||
} catch {
|
||||
return { errors: [`Cannot write to ${path.dirname(targetDir)}. Choose a different location.`] };
|
||||
}
|
||||
|
||||
// Stop the watcher before touching files on disk.
|
||||
repoFileWatcherRegistry.stopWatcher(repo._id);
|
||||
|
||||
try {
|
||||
let currentExists = false;
|
||||
try {
|
||||
currentExists = (await fs.promises.stat(currentBaseDir)).isDirectory();
|
||||
} catch {
|
||||
// Nothing on disk yet (e.g. an empty managed repo) — just create the target.
|
||||
}
|
||||
|
||||
if (currentExists) {
|
||||
try {
|
||||
await fs.promises.rename(currentBaseDir, targetDir);
|
||||
} catch (e) {
|
||||
// Cross-device moves (e.g. to an external drive) can't be renamed.
|
||||
if ((e as NodeJS.ErrnoException)?.code === 'EXDEV') {
|
||||
await fs.promises.cp(currentBaseDir, targetDir, { recursive: true });
|
||||
await fs.promises.rm(currentBaseDir, { recursive: true, force: true });
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await fs.promises.mkdir(targetDir, { recursive: true });
|
||||
}
|
||||
} catch (e) {
|
||||
// Move failed — leave the repo where it was and resume watching it.
|
||||
await repoFileWatcherRegistry.startWatcher(repo._id, currentBaseDir, projectId);
|
||||
return { errors: [`Failed to move repository: ${e instanceof Error ? e.message : String(e)}`] };
|
||||
}
|
||||
|
||||
// Persist the new location and re-point the in-memory VCS + watcher at it.
|
||||
await services.gitRepository.update(repo, { directory: targetDir });
|
||||
|
||||
const newFsClient = await getGitFSClient({ projectId, gitRepositoryId: repo._id, directory: targetDir });
|
||||
// Re-init only when this repo is the one currently loaded in the singleton VCS;
|
||||
// otherwise the next loadGitRepository will initialise it at the new location.
|
||||
if (GitVCS.isInitializedForRepo(repo._id)) {
|
||||
await GitVCS.init({
|
||||
repoId: repo._id,
|
||||
uri: repo.uri,
|
||||
directory: GIT_CLONE_DIR,
|
||||
fs: newFsClient,
|
||||
gitDirectory: GIT_INTERNAL_DIR,
|
||||
credentialsId: repo.credentialsId,
|
||||
});
|
||||
}
|
||||
await repoFileWatcherRegistry.startWatcher(repo._id, targetDir, projectId);
|
||||
|
||||
return { directory: targetDir };
|
||||
};
|
||||
|
||||
export const updateGitRepoAction = async ({
|
||||
projectId,
|
||||
workspaceId,
|
||||
@@ -1574,7 +1937,7 @@ export const updateGitRepoAction = async ({
|
||||
credentialsId: credentialsId,
|
||||
legacyDiff: Boolean(workspaceId),
|
||||
ref,
|
||||
repoPath: getGitBaseDir(gitRepository._id),
|
||||
repoPath: await getRepoBaseDir(gitRepository._id, gitRepository.directory),
|
||||
});
|
||||
|
||||
await GitVCS.setAuthor();
|
||||
@@ -1630,6 +1993,8 @@ export const resetGitRepoAction = async ({ projectId, workspaceId }: { projectId
|
||||
// Stop the file watcher for this repository (project-scoped flow only).
|
||||
repoFileWatcherRegistry.stopWatcher(repo._id);
|
||||
clearConflictSuppression(repo._id);
|
||||
// Remove the on-disk folder only when Insomnia owns it (never a user folder).
|
||||
await deleteManagedRepoFolderIfOwned(repo);
|
||||
|
||||
await database.flushChanges(flushId);
|
||||
|
||||
@@ -2991,11 +3356,19 @@ export async function runAllGitRepoMigrations(): Promise<MigrationSummary> {
|
||||
logs.push(`${ts()} [${level.toUpperCase()}] ["${project.name}"] ${message}`);
|
||||
};
|
||||
|
||||
const allowedBase = path.resolve(baseDataPath);
|
||||
const baseDir = path.resolve(allowedBase, 'version-control', 'git', repoId);
|
||||
if (!baseDir.startsWith(allowedBase + path.sep)) {
|
||||
logger('warn', `Skipping repo with unsafe path — repoId may contain path traversal: ${repoId}`);
|
||||
return;
|
||||
let baseDir: string;
|
||||
if (gitRepository.directory) {
|
||||
// User-located repos are created in the new on-disk layout, so the
|
||||
// legacy-structure migration is a no-op for them. Use their directory
|
||||
// directly — the managed-path traversal guard does not apply.
|
||||
baseDir = gitRepository.directory;
|
||||
} else {
|
||||
const allowedBase = path.resolve(baseDataPath);
|
||||
baseDir = path.resolve(allowedBase, 'version-control', 'git', repoId);
|
||||
if (!baseDir.startsWith(allowedBase + path.sep)) {
|
||||
logger('warn', `Skipping repo with unsafe path — repoId may contain path traversal: ${repoId}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const success = await migrateRepoStructureIfNeeded(baseDir, project._id, repoId, logger);
|
||||
@@ -3038,6 +3411,42 @@ export async function runAllGitRepoMigrations(): Promise<MigrationSummary> {
|
||||
return { logs, failedProjects, totalProjects: migratedCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up whether a folder is already adopted by an existing Git project. Used
|
||||
* by the project creation form to warn the user at folder-pick time (before they
|
||||
* try to open it) and to offer opening the existing project instead.
|
||||
*/
|
||||
export const checkGitRepoDirectoryAction = async ({
|
||||
directory,
|
||||
}: {
|
||||
directory: string;
|
||||
}): Promise<{ project: { _id: string; name: string; organizationId: string } | null }> => {
|
||||
try {
|
||||
if (!directory || !path.isAbsolute(directory)) {
|
||||
return { project: null };
|
||||
}
|
||||
const resolvedDirectory = path.resolve(directory);
|
||||
const existing = await services.gitRepository.getByDirectory(resolvedDirectory);
|
||||
if (!existing) {
|
||||
return { project: null };
|
||||
}
|
||||
|
||||
const projects = await database.find<GitProject>('Project', {});
|
||||
const project = projects.find(
|
||||
p => models.project.isConnectedGitProject(p) && models.project.getEffectiveRepoId(p) === existing._id,
|
||||
);
|
||||
if (!project) {
|
||||
return { project: null };
|
||||
}
|
||||
|
||||
return {
|
||||
project: { _id: project._id, name: project.name, organizationId: project.parentId },
|
||||
};
|
||||
} catch {
|
||||
return { project: null };
|
||||
}
|
||||
};
|
||||
|
||||
export interface GitServiceAPI {
|
||||
loadGitRepository: typeof loadGitRepository;
|
||||
getGitBranches: typeof getGitBranches;
|
||||
@@ -3047,6 +3456,10 @@ export interface GitServiceAPI {
|
||||
canPushLoader: typeof canPushLoader;
|
||||
initGitRepoClone: typeof initGitRepoCloneAction;
|
||||
cloneGitRepo: typeof cloneGitRepoAction;
|
||||
openGitRepo: typeof openGitRepoAction;
|
||||
checkGitRepoDirectory: typeof checkGitRepoDirectoryAction;
|
||||
cleanupGitRepoStorage: typeof cleanupGitRepoStorageAction;
|
||||
relocateGitRepo: typeof relocateGitRepoAction;
|
||||
updateGitRepo: typeof updateGitRepoAction;
|
||||
resetGitRepo: typeof resetGitRepoAction;
|
||||
commitToGitRepo: typeof commitToGitRepoAction;
|
||||
@@ -3115,6 +3528,16 @@ export const registerGitServiceAPI = () => {
|
||||
ipcMainHandle('git.cloneGitRepo', (_, options: Parameters<typeof cloneGitRepoAction>[0]) =>
|
||||
cloneGitRepoAction(options),
|
||||
);
|
||||
ipcMainHandle('git.openGitRepo', (_, options: Parameters<typeof openGitRepoAction>[0]) => openGitRepoAction(options));
|
||||
ipcMainHandle('git.checkGitRepoDirectory', (_, options: Parameters<typeof checkGitRepoDirectoryAction>[0]) =>
|
||||
checkGitRepoDirectoryAction(options),
|
||||
);
|
||||
ipcMainHandle('git.cleanupGitRepoStorage', (_, options: Parameters<typeof cleanupGitRepoStorageAction>[0]) =>
|
||||
cleanupGitRepoStorageAction(options),
|
||||
);
|
||||
ipcMainHandle('git.relocateGitRepo', (_, options: Parameters<typeof relocateGitRepoAction>[0]) =>
|
||||
relocateGitRepoAction(options),
|
||||
);
|
||||
ipcMainHandle('git.initGitRepoClone', (_, options: Parameters<typeof initGitRepoCloneAction>[0]) =>
|
||||
initGitRepoCloneAction(options),
|
||||
);
|
||||
|
||||
@@ -51,6 +51,7 @@ export type HandleChannels =
|
||||
| 'getExecution'
|
||||
| 'getLocalStorageDataFromFileOrigin'
|
||||
| 'git.abortMerge'
|
||||
| 'git.cleanupGitRepoStorage'
|
||||
| 'git.canPushLoader'
|
||||
| 'git.checkoutGitBranch'
|
||||
| 'git.cloneGitRepo'
|
||||
@@ -78,7 +79,10 @@ export type HandleChannels =
|
||||
| 'git.mergeGitBranch'
|
||||
| 'git.migrateLegacyInsomniaFolderToFile'
|
||||
| 'git.multipleCommitToGitRepo'
|
||||
| 'git.openGitRepo'
|
||||
| 'git.checkGitRepoDirectory'
|
||||
| 'git.pullFromGitRemote'
|
||||
| 'git.relocateGitRepo'
|
||||
| 'git.pushToGitRemote'
|
||||
| 'git.resetGitRepo'
|
||||
| 'git.runAllGitRepoMigrations'
|
||||
|
||||
@@ -634,6 +634,26 @@ export function createWindow(): ElectronBrowserWindow {
|
||||
hiddenBrowserWindow ? stopHiddenBrowserWindow() : createHiddenBrowserWindow();
|
||||
},
|
||||
},
|
||||
{
|
||||
// Simulates the OS "Open folder in Insomnia" flow without a packaged build
|
||||
// (Finder association / protocol handler only register for an installed app).
|
||||
label: `${MNEMONIC_SYM}Open folder in Insomnia…`,
|
||||
click: async () => {
|
||||
const window = BrowserWindow.getFocusedWindow() || mainBrowserWindow;
|
||||
if (!window) {
|
||||
return;
|
||||
}
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(window, {
|
||||
title: 'Open folder in Insomnia',
|
||||
buttonLabel: 'Open',
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
if (canceled || !filePaths[0]) {
|
||||
return;
|
||||
}
|
||||
window.webContents.send('shell:open', `insomnia://app/open-folder?path=${encodeURIComponent(filePaths[0])}`);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const toolsMenu: MenuItemConstructorOptions = {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
GIT_PROVIDER_COMPLETE_SIGN_IN_FETCHER_KEY,
|
||||
useGitProviderCompleteSignInFetcher,
|
||||
} from '~/routes/git-credentials.complete-sign-in';
|
||||
import { useProjectNewActionFetcher } from '~/routes/organization.$organizationId.project.new';
|
||||
import { isLoggedIn } from '~/ui/account/session';
|
||||
import { AnalyticsEvent, PENDING_IMPORT_ATTRIBUTION_KEY, trackImportEvent } from '~/ui/analytics';
|
||||
import { getLoginUrl } from '~/ui/auth-session-provider.client';
|
||||
@@ -51,6 +52,7 @@ import Modals from '~/ui/modals';
|
||||
import { createPlugin } from '~/ui/plugins/create';
|
||||
import { setTheme } from '~/ui/plugins/misc';
|
||||
import { plugins } from '~/ui/plugins/renderer-bridge';
|
||||
import { confirmOpenFolderTrust } from '~/ui/utils/git-folder-trust';
|
||||
|
||||
import type { Route } from './+types/root';
|
||||
|
||||
@@ -356,6 +358,10 @@ const Root = () => {
|
||||
useAuthDeepLinkHandler();
|
||||
|
||||
const { revalidate } = useRevalidator();
|
||||
// Used to adopt a folder opened from the OS — going through the project-creation
|
||||
// action (not a raw IPC) so React Router revalidates loaders afterwards.
|
||||
const openFolderFetcher = useProjectNewActionFetcher();
|
||||
const { submit: openFolderSubmit } = openFolderFetcher;
|
||||
const inflightFetchers = useFetchers();
|
||||
const ifInSubmission = inflightFetchers.some(f => f.formMethod === 'POST');
|
||||
const latestInSubmission = useLatest(ifInSubmission);
|
||||
@@ -399,6 +405,45 @@ const Root = () => {
|
||||
message: params.message,
|
||||
});
|
||||
}
|
||||
// Open a local folder as a Git project (from the OS: Finder "Open With",
|
||||
// `insomnia /path/to/folder`, or this deep link). The main process has
|
||||
// already verified the path is an existing directory
|
||||
if (urlWithoutParams === 'insomnia://app/open-folder') {
|
||||
const folderPath = params.path;
|
||||
if (!folderPath) {
|
||||
return;
|
||||
}
|
||||
const userSession = await services.userSession.get();
|
||||
if (!userSession.id) {
|
||||
window.sessionStorage.setItem('pendingDeepLinkAfterAuthorize', url);
|
||||
window.localStorage.setItem('logoutMessage', 'Please log in to open a folder in Insomnia.');
|
||||
return navigate(href('/auth/login'));
|
||||
}
|
||||
// Create the project in the currently active org, falling back to the
|
||||
// last-visited org (the running app always has one).
|
||||
const targetOrg =
|
||||
organizationId && organizationId !== models.organization.SCRATCHPAD_ORGANIZATION_ID
|
||||
? organizationId
|
||||
: window.localStorage.getItem('lastVisitedOrganizationId') || '';
|
||||
if (!targetOrg) {
|
||||
return navigate(href('/organization'));
|
||||
}
|
||||
// Opening a folder from the OS adopts whatever it contains — confirm trust first.
|
||||
if (!(await confirmOpenFolderTrust(folderPath))) {
|
||||
return;
|
||||
}
|
||||
// Go through the project-creation action (not a raw IPC) so React Router
|
||||
// revalidates loaders and the sidebar/project list update; the action's
|
||||
// redirect navigates to the new project.
|
||||
return openFolderSubmit({
|
||||
organizationId: targetOrg,
|
||||
projectData: {
|
||||
name: window.path.basename(folderPath) || 'New Git Project',
|
||||
storageType: 'git',
|
||||
openExistingDirectory: folderPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Supports params: uri, curl, origin
|
||||
if (urlWithoutParams === 'insomnia://app/import') {
|
||||
// Clean up the flag set during deep-link replay so it never leaks
|
||||
@@ -630,11 +675,20 @@ const Root = () => {
|
||||
createCloudCredentials,
|
||||
gitProviderCompleteSignInSubmit,
|
||||
navigate,
|
||||
openFolderSubmit,
|
||||
organizationId,
|
||||
projectId,
|
||||
redirectToDefaultBrowserSubmit,
|
||||
]);
|
||||
|
||||
// Surface failures from adopting an OS-opened folder (e.g. unreadable folder or
|
||||
// a collision with an existing project).
|
||||
useEffect(() => {
|
||||
if (openFolderFetcher.state === 'idle' && openFolderFetcher.data?.error) {
|
||||
showModal(AlertModal, { title: 'Could not open folder', message: openFolderFetcher.data.error });
|
||||
}
|
||||
}, [openFolderFetcher.data, openFolderFetcher.state]);
|
||||
|
||||
// Replay a deep link that was queued before login (e.g. insomnia://app/import
|
||||
// clicked while signed out). We wait for organizationId so that the full
|
||||
// redirect chain (org → project) has settled and the import modal can read
|
||||
|
||||
28
packages/insomnia/src/routes/git.relocate.tsx
Normal file
28
packages/insomnia/src/routes/git.relocate.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { href } from 'react-router';
|
||||
|
||||
import { createFetcherSubmitHook } from '~/ui/utils/router';
|
||||
|
||||
import type { Route } from './+types/git.relocate';
|
||||
|
||||
interface RelocateGitRepoParams {
|
||||
gitRepositoryId: string;
|
||||
projectId: string;
|
||||
newDirectory: string;
|
||||
}
|
||||
|
||||
export async function clientAction({ request }: Route.ClientActionArgs) {
|
||||
const data = (await request.json()) as RelocateGitRepoParams;
|
||||
|
||||
return window.main.git.relocateGitRepo(data);
|
||||
}
|
||||
|
||||
export const useGitProjectRelocateActionFetcher = createFetcherSubmitHook(
|
||||
submit => (data: RelocateGitRepoParams) => {
|
||||
return submit(JSON.stringify(data), {
|
||||
method: 'POST',
|
||||
action: href('/git/relocate'),
|
||||
encType: 'application/json',
|
||||
});
|
||||
},
|
||||
clientAction,
|
||||
);
|
||||
@@ -35,7 +35,12 @@ export async function clientAction({ params }: Route.ClientActionArgs) {
|
||||
if (models.project.isConnectedGitProject(project)) {
|
||||
const effectiveRepoId = models.project.isGitProject(project) ? models.project.getEffectiveRepoId(project) : null;
|
||||
const gitRepository = effectiveRepoId ? await services.gitRepository.getById(effectiveRepoId) : null;
|
||||
gitRepository && (await services.gitRepository.remove(gitRepository));
|
||||
if (gitRepository) {
|
||||
// Stop the watcher and delete the on-disk folder only if Insomnia owns it.
|
||||
// User-chosen folders are left untouched.
|
||||
await window.main.git.cleanupGitRepoStorage({ gitRepositoryId: gitRepository._id });
|
||||
await services.gitRepository.remove(gitRepository);
|
||||
}
|
||||
}
|
||||
|
||||
await services.stats.incrementDeletedRequestsForDescendents(project);
|
||||
|
||||
@@ -342,7 +342,12 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
|
||||
const effectiveId = models.project.isGitProject(project) ? models.project.getEffectiveRepoId(project) : null;
|
||||
const gitRepository = effectiveId ? await services.gitRepository.getById(effectiveId) : null;
|
||||
|
||||
gitRepository && (await services.gitRepository.remove(gitRepository));
|
||||
if (gitRepository) {
|
||||
// Stop the watcher and delete the folder only if Insomnia owns it; a
|
||||
// user-chosen folder stays on disk.
|
||||
await window.main.git.cleanupGitRepoStorage({ gitRepositoryId: gitRepository._id });
|
||||
await services.gitRepository.remove(gitRepository);
|
||||
}
|
||||
await services.project.update(project, { name, gitRepositoryId: null });
|
||||
|
||||
reportGitProjectCount(organizationId, sessionId);
|
||||
|
||||
@@ -69,6 +69,7 @@ import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data';
|
||||
import { useAIFeatureStatus } from '~/ui/hooks/use-organization-features';
|
||||
import { useGitVCSVersion } from '~/ui/hooks/use-vcs-version';
|
||||
import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils';
|
||||
import { resolveGitRepoBaseDir } from '~/ui/utils/git-repo-path';
|
||||
import { selectFileOrFolder } from '~/ui/utils/select-file-or-folder';
|
||||
|
||||
import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec';
|
||||
@@ -102,8 +103,11 @@ export async function clientLoader({ params }: Route.ClientLoaderArgs) {
|
||||
: workspaceMeta?.gitRepositoryId;
|
||||
// we don't run the lint here because it is expensive and slows first render too much
|
||||
// TODO: add this in once we run this loader outside the renderer
|
||||
const gitSyncRulesetPath = gitRepositoryId
|
||||
? window.path.join(window.app.getPath('userData'), `version-control/git/${gitRepositoryId}/.spectral.yaml`)
|
||||
// Honour user-chosen repo locations: the ruleset lives at the repo root, which
|
||||
// is the GitRepository's `directory` when set, else the managed location.
|
||||
const gitRepository = gitRepositoryId ? await services.gitRepository.getById(gitRepositoryId) : null;
|
||||
const gitSyncRulesetPath = gitRepository
|
||||
? window.path.join(resolveGitRepoBaseDir(gitRepository), '.spectral.yaml')
|
||||
: '';
|
||||
|
||||
// The ProjectLintRuleset record is the source of truth for both git and cloud projects.
|
||||
|
||||
@@ -24,6 +24,13 @@ export interface CreateProjectData {
|
||||
connectRepositoryLater?: boolean;
|
||||
ref?: string;
|
||||
selectedAuthorEmail?: string | null;
|
||||
/** Optional absolute path to a user-chosen folder to clone the repo into. */
|
||||
directory?: string | null;
|
||||
/**
|
||||
* When set, adopt this existing local folder as a Git project instead of
|
||||
* cloning from a URL.
|
||||
*/
|
||||
openExistingDirectory?: string;
|
||||
}
|
||||
|
||||
export const reportGitProjectCount = async (organizationId: string, sessionId: string, maxRetries = 3) => {
|
||||
@@ -74,6 +81,23 @@ const createProjectImpl = async (organizationId: string, newProjectData: CreateP
|
||||
return project._id;
|
||||
}
|
||||
|
||||
// Adopt an existing local folder rather than cloning from a URL.
|
||||
if (newProjectData.openExistingDirectory) {
|
||||
const { projectId, errors } = await window.main.git.openGitRepo({
|
||||
organizationId,
|
||||
name: newProjectData.name,
|
||||
directory: newProjectData.openExistingDirectory,
|
||||
credentialsId: newProjectData.credentialsId,
|
||||
});
|
||||
|
||||
if (errors) {
|
||||
throw new Error(errors.join(', '));
|
||||
}
|
||||
reportGitProjectCount(organizationId, sessionId);
|
||||
|
||||
return projectId;
|
||||
}
|
||||
|
||||
invariant(newProjectData.credentialsId, 'Credentials ID is required for Git project creation');
|
||||
const { projectId, errors } = await window.main.git.cloneGitRepo({
|
||||
organizationId,
|
||||
@@ -82,6 +106,7 @@ const createProjectImpl = async (organizationId: string, newProjectData: CreateP
|
||||
name: newProjectData.name,
|
||||
ref: newProjectData.ref || '',
|
||||
selectedAuthorEmail: newProjectData.selectedAuthorEmail,
|
||||
directory: newProjectData.directory,
|
||||
});
|
||||
|
||||
if (errors) {
|
||||
|
||||
@@ -344,6 +344,17 @@ class RepoFileWatcher {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the whole repo folder is gone (deleted/moved/unmounted), an empty disk
|
||||
// must NOT be read as "every file was deleted" — that would wipe the DB.
|
||||
// Treat the repo as temporarily unavailable and skip syncing.
|
||||
if (!(await this.repoDirIsAvailable())) {
|
||||
console.warn(
|
||||
'[repo-file-watcher] Repo directory unavailable — skipping import to avoid data loss:',
|
||||
this.repoDir,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const yamlFiles = await this.collectYamlFiles(this.repoDir);
|
||||
|
||||
// Import each file through the queue so they serialise with any
|
||||
@@ -615,6 +626,13 @@ class RepoFileWatcher {
|
||||
}
|
||||
|
||||
private async pollDirectory(dir: string): Promise<void> {
|
||||
// Don't sync when the repo folder is gone — see importAllFiles. This prevents
|
||||
// the deletion-detection loop below from wiping the DB when the folder was
|
||||
// deleted/unmounted rather than its files individually removed.
|
||||
if (!(await this.repoDirIsAvailable())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check known files for mtime changes or deletions — no readdir
|
||||
for (const [absPath, lastMtime] of this.lastSyncMtime) {
|
||||
try {
|
||||
@@ -736,7 +754,12 @@ class RepoFileWatcher {
|
||||
try {
|
||||
fileStat = await fs.promises.lstat(absPath);
|
||||
} catch {
|
||||
await this.handleFileDeletion(normalised);
|
||||
// Only treat a missing file as a real deletion when the repo folder itself
|
||||
// still exists. If the whole folder is gone (deleted/unmounted) we must not
|
||||
// remove the workspace from the DB — the repo is unavailable, not emptied.
|
||||
if (await this.repoDirIsAvailable()) {
|
||||
await this.handleFileDeletion(normalised);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -980,6 +1003,23 @@ class RepoFileWatcher {
|
||||
return rel.startsWith(GIT_DIR + path.sep) || rel === GIT_DIR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the repository's working-tree directory still exists on disk.
|
||||
*
|
||||
* When the user deletes/moves the folder (or unmounts its drive) the disk
|
||||
* appears empty. We must NOT interpret that as "all files deleted" and wipe the
|
||||
* database — instead we treat the repo as temporarily unavailable and skip
|
||||
* syncing, so the project's collections survive (and re-sync if the folder
|
||||
* returns).
|
||||
*/
|
||||
private async repoDirIsAvailable(): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.promises.stat(this.repoDir)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively collect all `.yaml` files under `dir` as normalised absolute paths, skipping `.git`. */
|
||||
private async collectYamlFiles(dir: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
|
||||
28
packages/insomnia/src/ui/components/base/middle-truncate.tsx
Normal file
28
packages/insomnia/src/ui/components/base/middle-truncate.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React, { type FC } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
interface Props {
|
||||
/** The full text to display; the full value is also used as the hover title. */
|
||||
value: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a file-system-style path truncated in the middle: the leading portion
|
||||
* collapses with an ellipsis while the trailing segment (e.g. the folder/repo
|
||||
* name) stays visible. The full value is exposed via the `title` attribute so it
|
||||
* shows on hover.
|
||||
*/
|
||||
export const MiddleTruncate: FC<Props> = ({ value, className }) => {
|
||||
const sep = value.includes('\\') ? '\\' : '/';
|
||||
const idx = value.lastIndexOf(sep);
|
||||
const head = idx !== -1 ? value.slice(0, idx + 1) : '';
|
||||
const tail = idx !== -1 ? value.slice(idx + 1) : value;
|
||||
|
||||
return (
|
||||
<div title={value} className={twMerge('flex min-w-0 items-center', className)}>
|
||||
<span className="truncate">{head}</span>
|
||||
{tail && <span className="shrink-0 whitespace-pre">{tail}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -30,6 +30,7 @@ import { showSettingsModal } from '~/ui/components/modals/settings-modal';
|
||||
import { useGitCredentials } from '~/ui/hooks/use-git-credentials';
|
||||
import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data';
|
||||
import { DEFAULT_STORAGE_RULES } from '~/ui/organization-utils';
|
||||
import { resolveGitRepoBaseDir } from '~/ui/utils/git-repo-path';
|
||||
|
||||
import type { MergeConflict } from '../../../sync/types';
|
||||
import { GitNonOriginBranchBanner } from '../git/git-non-origin-branch-banner';
|
||||
@@ -570,9 +571,7 @@ export const GitProjectSyncDropdown: FC<Props> = ({ gitRepository, activeProject
|
||||
]
|
||||
: [];
|
||||
|
||||
const repoPath = gitRepository?._id
|
||||
? window.path.join(window.app.getPath('userData'), 'version-control', 'git', gitRepository._id)
|
||||
: '';
|
||||
const repoPath = gitRepository?._id ? resolveGitRepoBaseDir(gitRepository) : '';
|
||||
|
||||
const gitSyncActions: {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { GitCredentials } from 'insomnia-data';
|
||||
import { type FC, Fragment, useState } from 'react';
|
||||
import { Button, Label, ListBox, ListBoxItem, Popover, Select, Separator } from 'react-aria-components';
|
||||
|
||||
import { Icon } from '~/basic-components/icon';
|
||||
import type { GitProviderOption } from '~/sync/git/providers/types';
|
||||
import { showSettingsModal } from '~/ui/components/modals/settings-modal';
|
||||
|
||||
interface Props {
|
||||
credentials: GitCredentials[];
|
||||
providers: GitProviderOption[];
|
||||
selectedCredentialsId: string | null | undefined;
|
||||
onChange: (credentialsId: string) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picker for the Git credentials associated with a repository. Defaults to the
|
||||
* native (system git) credentials, which require no remote configuration. Used
|
||||
* by the "Open local folder" flow.
|
||||
*/
|
||||
export const GitCredentialSelect: FC<Props> = ({
|
||||
credentials,
|
||||
providers,
|
||||
selectedCredentialsId,
|
||||
onChange,
|
||||
label = 'Git credentials',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const selected = credentials.find(c => c._id === selectedCredentialsId);
|
||||
const selectedProvider = providers.find(p => p.type === selected?.provider);
|
||||
|
||||
return (
|
||||
<Select
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
aria-label={label}
|
||||
selectedKey={selected?._id ?? null}
|
||||
onSelectionChange={key => key && onChange(String(key))}
|
||||
>
|
||||
<Label className="mb-2 px-0.5 pt-0 text-sm text-(--color-font)">{label}</Label>
|
||||
<Button className="flex w-full flex-1 items-center justify-between gap-2 rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 py-1 text-(--color-font) ring-1 ring-transparent transition-colors hover:bg-(--hl-xs) focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden focus:ring-inset aria-pressed:bg-(--hl-sm)">
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
{selected ? (
|
||||
<Fragment>
|
||||
{selectedProvider?.iconName && <Icon icon={selectedProvider.iconName} className="size-4" />}
|
||||
<span>{selectedProvider?.displayName}</span>
|
||||
{selected.author?.name && (
|
||||
<Fragment>
|
||||
<Separator orientation="vertical" className="mx-2 h-4 border-l border-(--color-font)" />
|
||||
<span className="truncate">{selected.author.name}</span>
|
||||
</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
) : (
|
||||
'Select credentials'
|
||||
)}
|
||||
</span>
|
||||
<Icon icon="caret-down" />
|
||||
</Button>
|
||||
<Popover className="isolate flex w-(--trigger-width) min-w-max flex-col overflow-hidden rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) text-sm shadow-lg select-none">
|
||||
<ListBox className="min-w-max overflow-y-auto py-2 focus:outline-hidden">
|
||||
{credentials.map(item => {
|
||||
const provider = providers.find(p => p.type === item.provider);
|
||||
return (
|
||||
<ListBoxItem
|
||||
id={item._id}
|
||||
key={item._id}
|
||||
aria-label={item.name}
|
||||
textValue={item.name}
|
||||
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 aria-selected:font-bold"
|
||||
>
|
||||
{provider?.iconName && <Icon icon={provider.iconName} className="size-4" />}
|
||||
<span>{provider?.displayName}</span>
|
||||
{item.author?.name && (
|
||||
<Fragment>
|
||||
<Separator orientation="vertical" className="mx-2 h-4 border-l border-(--color-font)" />
|
||||
<span className="truncate">{item.author.name}</span>
|
||||
</Fragment>
|
||||
)}
|
||||
</ListBoxItem>
|
||||
);
|
||||
})}
|
||||
</ListBox>
|
||||
<div className="w-(--trigger-width) bg-(--hl-xs) p-4 text-sm text-(--color-font)">
|
||||
<Button
|
||||
onPress={() => {
|
||||
setIsOpen(false);
|
||||
showSettingsModal({ tab: 'credentials' });
|
||||
}}
|
||||
className="underline"
|
||||
>
|
||||
Manage credentials
|
||||
</Button>
|
||||
</div>
|
||||
</Popover>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
@@ -58,6 +58,7 @@ import { isGitRepoLoadAuthHttp40Error } from '~/ui/components/git/git-oauth-auth
|
||||
import { showSettingsModal } from '~/ui/components/modals/settings-modal';
|
||||
import { SvgIcon } from '~/ui/components/svg-icon';
|
||||
import { useAIFeatureStatus } from '~/ui/hooks/use-organization-features';
|
||||
import { resolveGitRepoBaseDir } from '~/ui/utils/git-repo-path';
|
||||
|
||||
import { DiffEditor } from '../diff-view-editor';
|
||||
import { Icon } from '../icon';
|
||||
@@ -621,9 +622,7 @@ const ManualCommitForm: FC<ManualCommitFormProps> = ({
|
||||
const [operationError, setOperationError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const repoPath = gitRepository?._id
|
||||
? window.path.join(window.app.getPath('userData'), 'version-control', 'git', gitRepository._id)
|
||||
: '';
|
||||
const repoPath = gitRepository?._id ? resolveGitRepoBaseDir(gitRepository) : '';
|
||||
const completeSignInFetcher = useGitProviderCompleteSignInFetcher({ key: GIT_PROVIDER_COMPLETE_SIGN_IN_FETCHER_KEY });
|
||||
const prevCompleteSignInStateRef = useRef(completeSignInFetcher.state);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useParams } from 'react-router';
|
||||
import { useGitProjectResetActionFetcher } from '~/routes/git.reset';
|
||||
import { GitConnectionInfo } from '~/ui/components/git/connection-info';
|
||||
import { useGitCredentials } from '~/ui/hooks/use-git-credentials';
|
||||
import { resolveGitRepoBaseDir } from '~/ui/utils/git-repo-path';
|
||||
|
||||
import { docsGitSync } from '../../../../common/documentation';
|
||||
import { Link } from '../../base/link';
|
||||
@@ -39,6 +40,7 @@ export const GitRepositorySettingsModal = ({
|
||||
}, []);
|
||||
|
||||
const authorEmail = gitRepository.selectedAuthorEmail || selectedCredential?.author?.email;
|
||||
const repoBaseDir = resolveGitRepoBaseDir(gitRepository);
|
||||
|
||||
return (
|
||||
<OverlayContainer>
|
||||
@@ -61,10 +63,23 @@ export const GitRepositorySettingsModal = ({
|
||||
)}
|
||||
{authorEmail && (
|
||||
<div className="mt-4 flex text-[12px]">
|
||||
<div className="w-[110px] font-semibold">Author Email</div>
|
||||
<div className="w-27.5 font-semibold">Author Email</div>
|
||||
<div>{authorEmail}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex items-start text-[12px]">
|
||||
<div className="w-27.5 font-semibold">Local folder</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 break-all">{repoBaseDir}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--super-compact btn--outlined"
|
||||
onClick={() => window.shell.showItemInFolder(repoBaseDir)}
|
||||
>
|
||||
Reveal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<div
|
||||
|
||||
@@ -58,7 +58,7 @@ export const ProjectModal = ({
|
||||
if (!open) requestClose();
|
||||
}}
|
||||
isDismissable={false}
|
||||
className="fixed top-0 right-0 bottom-0 left-0 z-10 flex items-start justify-center bg-black/30 pt-[70px]"
|
||||
className="fixed top-0 left-0 z-10 flex h-(--visual-viewport-height) w-full items-start justify-center bg-black/30 pt-(--padding-xl)"
|
||||
>
|
||||
<Modal
|
||||
ref={modalRef}
|
||||
|
||||
@@ -13,17 +13,19 @@ interface Props {
|
||||
export const NoProjectView: FC<Props> = ({ storageRules }) => {
|
||||
const { credentials, providers } = useGitCredentials();
|
||||
return (
|
||||
<div className="m-auto grid w-[min(700px,100%)] grid-rows-[min-content_1fr_min-content] place-items-stretch items-stretch gap-4 overflow-hidden p-16 text-(--color-font)">
|
||||
<div>
|
||||
<p className="mb-3 text-3xl font-semibold">Welcome to your organization!</p>
|
||||
<Heading className="mb-3">Create a new project to get started</Heading>
|
||||
<div className="h-full w-full overflow-y-auto text-(--color-font)">
|
||||
<div className="mx-auto flex w-[min(700px,100%)] flex-col gap-4 p-16">
|
||||
<div>
|
||||
<p className="mb-3 text-3xl font-semibold">Welcome to your organization!</p>
|
||||
<Heading className="mb-3">Create a new project to get started</Heading>
|
||||
</div>
|
||||
<ProjectCreateForm
|
||||
storageRules={storageRules}
|
||||
defaultProjectName="My first project"
|
||||
credentials={credentials}
|
||||
providers={providers}
|
||||
/>
|
||||
</div>
|
||||
<ProjectCreateForm
|
||||
storageRules={storageRules}
|
||||
defaultProjectName="My first project"
|
||||
credentials={credentials}
|
||||
providers={providers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,14 +21,16 @@ import type { GitProviderOption } from '~/sync/git/providers/types';
|
||||
import { ensureGitRepoUrlSuffix } from '~/sync/git/url-utils';
|
||||
import { Checkbox } from '~/ui/components/base/checkbox';
|
||||
import { Input } from '~/ui/components/base/input';
|
||||
import { MiddleTruncate } from '~/ui/components/base/middle-truncate';
|
||||
import { GitOauthAuthBanner } from '~/ui/components/git/git-oauth-auth-banner';
|
||||
import { GitCredentialSetup } from '~/ui/components/git-credentials/credential-setup';
|
||||
import { GitRemoteBranchSelect } from '~/ui/components/git-credentials/git-remote-branch-select';
|
||||
import { GitRepositorySelect } from '~/ui/components/git-credentials/git-repository-select';
|
||||
import { showSettingsModal } from '~/ui/components/modals/settings-modal';
|
||||
import { selectFileOrFolder } from '~/ui/utils/select-file-or-folder';
|
||||
|
||||
import { ErrorBoundary } from '../error-boundary';
|
||||
import type { ActiveView, ProjectData } from './utils';
|
||||
import { type ActiveView, deriveRepoName, getLastCloneParentDir, type ProjectData, setLastCloneParentDir } from './utils';
|
||||
|
||||
const { isGitCredentialsV2, isOAuthCredential } = models.gitCredentials;
|
||||
|
||||
@@ -158,8 +160,8 @@ export const GitRepoForm: FC<Props> = ({
|
||||
}}
|
||||
defaultSelectedKey={credentials?.[0]?._id}
|
||||
>
|
||||
<Label className="mb-2 px-0.5 pt-0 text-sm">Authorized as</Label>
|
||||
<Button className="flex w-full flex-1 items-center justify-between gap-2 rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 py-1 text-(--color-font) ring-1 ring-transparent transition-colors placeholder:italic hover:bg-(--hl-xs) focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden focus:ring-inset aria-pressed:bg-(--hl-sm)">
|
||||
<Label className="mb-1 pt-0 text-sm">Authorized as</Label>
|
||||
<Button className="flex h-(--line-height-xs) w-full flex-1 items-center justify-between gap-2 rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 text-(--color-font) ring-1 ring-transparent transition-colors placeholder:italic hover:bg-(--hl-xs) focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden focus:ring-inset aria-pressed:bg-(--hl-sm)">
|
||||
<SelectValue<GitCredentials> className="flex items-center justify-center gap-2 truncate">
|
||||
{({ selectedItem }) => {
|
||||
if (selectedItem) {
|
||||
@@ -254,8 +256,8 @@ export const GitRepoForm: FC<Props> = ({
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Label className="mb-2 px-0.5 pt-0 text-sm">Author Email</Label>
|
||||
<Button className="flex w-full flex-1 items-center justify-between gap-2 rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 py-1 text-(--color-font) ring-1 ring-transparent transition-colors placeholder:italic hover:bg-(--hl-xs) focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden focus:ring-inset aria-pressed:bg-(--hl-sm)">
|
||||
<Label className="mb-1 pt-0 text-sm">Author Email</Label>
|
||||
<Button className="flex h-(--line-height-xs) w-full flex-1 items-center justify-between gap-2 rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 text-(--color-font) ring-1 ring-transparent transition-colors placeholder:italic hover:bg-(--hl-xs) focus:ring-1 focus:ring-(--hl-md) focus:outline-hidden focus:ring-inset aria-pressed:bg-(--hl-sm)">
|
||||
<SelectValue<ProviderEmail> className="flex items-center justify-center gap-2 truncate">
|
||||
{({ selectedItem }) => {
|
||||
if (selectedItem) {
|
||||
@@ -337,6 +339,52 @@ export const GitRepoForm: FC<Props> = ({
|
||||
isDisabled={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={isCredentialInvalid ? 'hidden' : 'flex flex-col gap-2 px-0.5'}>
|
||||
<Label className="text-sm text-(--color-font)">Clone location</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{projectData.cloneParentDir ? (
|
||||
<MiddleTruncate
|
||||
value={window.path.join(projectData.cloneParentDir, deriveRepoName(projectData.uri))}
|
||||
className="h-(--line-height-xs) flex-1 rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 text-(--color-font)"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-(--line-height-xs) flex-1 items-center truncate rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 text-(--color-font)">
|
||||
Managed by Insomnia (default location)
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onPress={async () => {
|
||||
const defaultPath =
|
||||
projectData.cloneParentDir ||
|
||||
getLastCloneParentDir() ||
|
||||
window.path.join(window.app.getPath('home'), 'Insomnia');
|
||||
const { canceled, filePath } = await selectFileOrFolder({
|
||||
itemTypes: ['directory'],
|
||||
defaultPath,
|
||||
});
|
||||
if (canceled || !filePath) {
|
||||
return;
|
||||
}
|
||||
setLastCloneParentDir(filePath);
|
||||
setProjectData(prev => ({ ...prev, cloneParentDir: filePath }));
|
||||
}}
|
||||
className="flex h-(--line-height-xs) items-center justify-center gap-2 rounded-xs border border-solid border-(--hl-md) px-3 text-sm text-(--color-font) transition-colors hover:bg-(--hl-xs) aria-pressed:bg-(--hl-xs)"
|
||||
>
|
||||
Choose folder…
|
||||
</Button>
|
||||
{projectData.cloneParentDir && (
|
||||
<Button
|
||||
type="button"
|
||||
onPress={() => setProjectData(prev => ({ ...prev, cloneParentDir: undefined }))}
|
||||
className="flex h-(--line-height-xs) items-center justify-center gap-2 rounded-xs border border-solid border-(--hl-md) px-3 text-sm text-(--color-font) transition-colors hover:bg-(--hl-xs) aria-pressed:bg-(--hl-xs)"
|
||||
>
|
||||
Use default
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -3,17 +3,21 @@ import type { GitCredentials } from 'insomnia-data';
|
||||
import type { FC } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Input, Label, TextField } from 'react-aria-components';
|
||||
import { useParams } from 'react-router';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { useGitProjectInitCloneActionFetcher } from '~/routes/git.init-clone';
|
||||
import { useProjectNewActionFetcher } from '~/routes/organization.$organizationId.project.new';
|
||||
import type { GitProviderOption } from '~/sync/git/providers/types';
|
||||
import { MiddleTruncate } from '~/ui/components/base/middle-truncate';
|
||||
import { GitCredentialSelect } from '~/ui/components/git-credentials/git-credential-select';
|
||||
import { GitRepoForm } from '~/ui/components/project/git-repo-form';
|
||||
import { GitRepoScanResult } from '~/ui/components/project/git-repo-scan-result';
|
||||
import { ProjectTypeSelect } from '~/ui/components/project/project-type-select';
|
||||
import { ProjectTypeWarning } from '~/ui/components/project/project-type-warning';
|
||||
import { type ProjectData, type ProjectType, useActiveView } from '~/ui/components/project/utils';
|
||||
import { deriveRepoName, getLastCloneParentDir, type ProjectData, type ProjectType, useActiveView } from '~/ui/components/project/utils';
|
||||
import { useIsGitSyncEnabled } from '~/ui/hooks/use-organization-features';
|
||||
import { confirmOpenFolderTrust } from '~/ui/utils/git-folder-trust';
|
||||
import { selectFileOrFolder } from '~/ui/utils/select-file-or-folder';
|
||||
|
||||
import { Icon } from '../icon';
|
||||
|
||||
@@ -39,6 +43,7 @@ export const ProjectCreateForm: FC<Props> = ({
|
||||
onDirtyChange,
|
||||
}) => {
|
||||
const { organizationId } = useParams() as { organizationId: string };
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isGitSyncEnabled = useIsGitSyncEnabled(organizationId);
|
||||
|
||||
@@ -52,12 +57,29 @@ export const ProjectCreateForm: FC<Props> = ({
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isGitCredentialInvalid, setIsGitCredentialInvalid] = useState(false);
|
||||
// Git projects can either clone from a URL or adopt an existing local folder.
|
||||
const [gitMode, setGitMode] = useState<'clone' | 'open'>('clone');
|
||||
const [openExistingDir, setOpenExistingDir] = useState('');
|
||||
// Set when the picked folder is already adopted by another project; blocks
|
||||
// continuing and offers to open that project instead.
|
||||
const [existingFolderProject, setExistingFolderProject] = useState<{
|
||||
_id: string;
|
||||
name: string;
|
||||
organizationId: string;
|
||||
} | null>(null);
|
||||
|
||||
// Local repositories default to the native (system git) credentials, which
|
||||
// are always present as a seeded singleton and need no remote configuration.
|
||||
const nativeCredentialsId = credentials.find(c => c.provider === 'native')?._id;
|
||||
|
||||
const [projectData, setProjectData] = useState<ProjectData>({
|
||||
name: defaultProjectName,
|
||||
uri: '',
|
||||
credentialsId: undefined,
|
||||
connectRepositoryLater: false,
|
||||
// Default to the folder the user last cloned into so repeated clones land in
|
||||
// the same place without re-picking.
|
||||
cloneParentDir: getLastCloneParentDir() || undefined,
|
||||
});
|
||||
|
||||
const initCloneGitRepositoryFetcher = useGitProjectInitCloneActionFetcher();
|
||||
@@ -86,20 +108,69 @@ export const ProjectCreateForm: FC<Props> = ({
|
||||
}
|
||||
}, [newProjectFetcher.data, newProjectFetcher.state]);
|
||||
|
||||
const onUpsertProject = () => {
|
||||
const isGitOpen = storageType === 'git' && gitMode === 'open';
|
||||
|
||||
// Preselect native git credentials when adopting a local folder so the user
|
||||
// isn't forced to pick before continuing.
|
||||
useEffect(() => {
|
||||
if (isGitOpen && !projectData.credentialsId && nativeCredentialsId) {
|
||||
setProjectData(prev => ({ ...prev, credentialsId: nativeCredentialsId }));
|
||||
}
|
||||
}, [isGitOpen, projectData.credentialsId, nativeCredentialsId]);
|
||||
|
||||
const onUpsertProject = async () => {
|
||||
if (!storageType) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Never adopt a folder that's already owned by another project.
|
||||
if (isGitOpen && existingFolderProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Opening an arbitrary local folder pulls in whatever it contains, so ask
|
||||
// the user to confirm they trust it first.
|
||||
if (isGitOpen && openExistingDir && !(await confirmOpenFolderTrust(openExistingDir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For a custom clone location, the picked folder is the parent — clone into
|
||||
// `<parent>/<repo-name>`, matching `git clone` behaviour.
|
||||
const directory =
|
||||
storageType === 'git' && !isGitOpen && !projectData.connectRepositoryLater && projectData.cloneParentDir
|
||||
? window.path.join(projectData.cloneParentDir, deriveRepoName(projectData.uri))
|
||||
: undefined;
|
||||
|
||||
newProjectFetcher.submit({
|
||||
organizationId,
|
||||
projectData: {
|
||||
...projectData,
|
||||
storageType,
|
||||
directory,
|
||||
// Adopting a folder takes its own path — never treat it as connect-later.
|
||||
...(isGitOpen ? { openExistingDirectory: openExistingDir, connectRepositoryLater: false } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const hideActionButtons = storageType === 'git' && !projectData.connectRepositoryLater && credentials.length === 0;
|
||||
const onChooseExistingFolder = async () => {
|
||||
const { canceled, filePath } = await selectFileOrFolder({
|
||||
itemTypes: ['directory'],
|
||||
defaultPath: openExistingDir || window.app.getPath('home'),
|
||||
});
|
||||
if (canceled || !filePath) {
|
||||
return;
|
||||
}
|
||||
setOpenExistingDir(filePath);
|
||||
// Check for an existing project right after picking, so the warning shows
|
||||
// before the user tries to open the folder.
|
||||
const { project } = await window.main.git.checkGitRepoDirectory({ directory: filePath });
|
||||
setExistingFolderProject(project);
|
||||
};
|
||||
|
||||
// Credentials are only required for the clone flow; opening a folder needs none.
|
||||
const hideActionButtons =
|
||||
storageType === 'git' && gitMode === 'clone' && !projectData.connectRepositoryLater && credentials.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -140,17 +211,90 @@ export const ProjectCreateForm: FC<Props> = ({
|
||||
storageRules={storageRules}
|
||||
/>
|
||||
{storageType === 'git' && isGitSyncEnabled && (
|
||||
<GitRepoForm
|
||||
formId={FORMID}
|
||||
projectData={projectData}
|
||||
setProjectData={setProjectData}
|
||||
initCloneGitRepositoryFetcher={initCloneGitRepositoryFetcher}
|
||||
organizationId={organizationId}
|
||||
setActiveView={setActiveView}
|
||||
credentials={credentials}
|
||||
providers={providers}
|
||||
onCredentialValidationChange={setIsGitCredentialInvalid}
|
||||
/>
|
||||
<>
|
||||
<div className="flex w-full gap-2">
|
||||
{(['clone', 'open'] as const).map(mode => (
|
||||
<Button
|
||||
key={mode}
|
||||
onPress={() => setGitMode(mode)}
|
||||
className={`flex flex-1 items-center justify-center gap-2 rounded-xs border border-solid px-3 py-1.5 text-sm transition-colors ${
|
||||
gitMode === mode
|
||||
? 'border-(--color-surprise) bg-(--hl-xs) text-(--color-font)'
|
||||
: 'border-(--hl-md) text-(--color-font) hover:bg-(--hl-xs)'
|
||||
}`}
|
||||
>
|
||||
<Icon icon={mode === 'clone' ? 'cloud-arrow-down' : 'folder-open'} />
|
||||
{mode === 'clone' ? 'Clone from Remote' : 'Open local folder'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{gitMode === 'clone' ? (
|
||||
<GitRepoForm
|
||||
formId={FORMID}
|
||||
projectData={projectData}
|
||||
setProjectData={setProjectData}
|
||||
initCloneGitRepositoryFetcher={initCloneGitRepositoryFetcher}
|
||||
organizationId={organizationId}
|
||||
setActiveView={setActiveView}
|
||||
credentials={credentials}
|
||||
providers={providers}
|
||||
onCredentialValidationChange={setIsGitCredentialInvalid}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 px-0.5">
|
||||
<Label className="text-sm text-(--color-font)">Folder</Label>
|
||||
<p className="text-xs">
|
||||
Insomnia will open this folder as a Git project. If it isn't a git repository yet, one will be
|
||||
initialized.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{openExistingDir ? (
|
||||
<MiddleTruncate
|
||||
value={openExistingDir}
|
||||
className="h-(--line-height-xs) flex-1 rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 text-(--color-font)"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-(--line-height-xs) flex-1 items-center truncate rounded-xs border border-solid border-(--hl-sm) bg-(--color-bg) px-2 text-(--color-font)">
|
||||
No folder selected
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onPress={onChooseExistingFolder}
|
||||
className="flex h-(--line-height-xs) items-center justify-center gap-2 rounded-xs border border-solid border-(--hl-md) px-3 text-sm text-(--color-font) transition-colors hover:bg-(--hl-xs) aria-pressed:bg-(--hl-xs)"
|
||||
>
|
||||
Choose folder…
|
||||
</Button>
|
||||
</div>
|
||||
{existingFolderProject && (
|
||||
<div className="flex flex-col gap-1 text-sm text-(--color-danger)">
|
||||
<span>
|
||||
A project ("{existingFolderProject.name}") is already connected to this folder. Please select
|
||||
another folder, or open the existing project.
|
||||
</span>
|
||||
<Button
|
||||
onPress={() => {
|
||||
navigate(
|
||||
`/organization/${existingFolderProject.organizationId}/project/${existingFolderProject._id}`,
|
||||
);
|
||||
onCancel?.();
|
||||
}}
|
||||
className="self-start underline"
|
||||
>
|
||||
Open project
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<GitCredentialSelect
|
||||
credentials={credentials}
|
||||
providers={providers}
|
||||
selectedCredentialsId={projectData.credentialsId ?? nativeCredentialsId}
|
||||
onChange={credentialsId => setProjectData(prev => ({ ...prev, credentialsId }))}
|
||||
label="Git credentials"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -176,14 +320,18 @@ export const ProjectCreateForm: FC<Props> = ({
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{storageType !== 'git' || projectData.connectRepositoryLater ? (
|
||||
{storageType !== 'git' || projectData.connectRepositoryLater || isGitOpen ? (
|
||||
<Button
|
||||
onPress={onUpsertProject}
|
||||
isDisabled={!storageType || newProjectFetcher.state !== 'idle'}
|
||||
isDisabled={
|
||||
!storageType ||
|
||||
newProjectFetcher.state !== 'idle' ||
|
||||
(isGitOpen && (!openExistingDir || !!existingFolderProject))
|
||||
}
|
||||
className="flex h-full w-[10ch] items-center justify-center gap-2 rounded-md border border-solid border-(--hl-md) bg-(--color-surprise) px-4 py-2 text-sm font-semibold text-(--color-font-surprise) ring-1 ring-transparent transition-all hover:bg-(--color-surprise)/80 focus:ring-(--hl-md) focus:ring-inset aria-pressed:opacity-80"
|
||||
>
|
||||
{newProjectFetcher.state !== 'idle' && <Icon icon="spinner" className="animate-spin" />}
|
||||
<span>Create</span>
|
||||
<span>{isGitOpen ? 'Open' : 'Create'}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Banner } from '~/basic-components/banner';
|
||||
import { Divider } from '~/basic-components/divider';
|
||||
import { LearnMoreLink } from '~/basic-components/link';
|
||||
import { useGitProjectInitCloneActionFetcher } from '~/routes/git.init-clone';
|
||||
import { useGitProjectRelocateActionFetcher } from '~/routes/git.relocate';
|
||||
import { useGitValidateCredentialsFetcher } from '~/routes/git.validate-credentials';
|
||||
import { useGitProviderEmailsLoaderFetcher } from '~/routes/git-provider.emails';
|
||||
import type { GitProviderOption } from '~/sync/git/providers/types';
|
||||
@@ -32,9 +33,11 @@ import { GitRepoForm } from '~/ui/components/project/git-repo-form';
|
||||
import { GitRepoScanResult } from '~/ui/components/project/git-repo-scan-result';
|
||||
import { ProjectTypeSelect } from '~/ui/components/project/project-type-select';
|
||||
import { ProjectTypeWarning } from '~/ui/components/project/project-type-warning';
|
||||
import { useActiveView } from '~/ui/components/project/utils';
|
||||
import { deriveRepoName, useActiveView } from '~/ui/components/project/utils';
|
||||
import { useIsLightTheme } from '~/ui/hooks/theme';
|
||||
import { useIsGitSyncEnabled } from '~/ui/hooks/use-organization-features';
|
||||
import { resolveGitRepoBaseDir } from '~/ui/utils/git-repo-path';
|
||||
import { selectFileOrFolder } from '~/ui/utils/select-file-or-folder';
|
||||
|
||||
import { useProjectUpdateActionFetcher } from '../../../routes/organization.$organizationId.project.$projectId.update';
|
||||
import { Icon } from '../icon';
|
||||
@@ -118,6 +121,7 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
const initCloneGitRepositoryFetcher = useGitProjectInitCloneActionFetcher();
|
||||
const validateCredentialsFetcher = useGitValidateCredentialsFetcher();
|
||||
const updateProjectFetcher = useProjectUpdateActionFetcher();
|
||||
const relocateFetcher = useGitProjectRelocateActionFetcher();
|
||||
|
||||
const insomniaFiles =
|
||||
initCloneGitRepositoryFetcher.data && 'files' in initCloneGitRepositoryFetcher.data
|
||||
@@ -148,6 +152,17 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
if (onDirtyChange) onDirtyChange(changedFieldCount > 1);
|
||||
}, [onDirtyChange, changedFieldCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
relocateFetcher.state === 'idle' &&
|
||||
relocateFetcher.data &&
|
||||
'errors' in relocateFetcher.data &&
|
||||
relocateFetcher.data.errors
|
||||
) {
|
||||
setError(relocateFetcher.data.errors.join(', '));
|
||||
}
|
||||
}, [relocateFetcher.data, relocateFetcher.state]);
|
||||
|
||||
const onUpsertProject = () => {
|
||||
if (project) {
|
||||
updateProjectFetcher.submit({
|
||||
@@ -179,9 +194,37 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
project?.gitRepositoryId !== models.project.EMPTY_GIT_PROJECT_ID &&
|
||||
Boolean(gitRepository?._id);
|
||||
|
||||
const repoPath = showRepoPath
|
||||
? window.path.join(window.app.getPath('userData'), 'version-control', 'git', gitRepository!._id)
|
||||
: '';
|
||||
// Relocation goes through a route action (declared above with the other
|
||||
// fetchers) so loaders revalidate afterwards. `relocatedDir` from the action
|
||||
// result shows the new path immediately, before the revalidated
|
||||
// `gitRepository` prop arrives.
|
||||
const isRelocating = relocateFetcher.state !== 'idle';
|
||||
const relocatedDir =
|
||||
relocateFetcher.data && 'directory' in relocateFetcher.data ? relocateFetcher.data.directory : null;
|
||||
const repoPath = showRepoPath ? relocatedDir || resolveGitRepoBaseDir(gitRepository!) : '';
|
||||
|
||||
const onRelocateRepo = async () => {
|
||||
if (!project || !gitRepository) {
|
||||
return;
|
||||
}
|
||||
const picked = await selectFileOrFolder({
|
||||
itemTypes: ['directory'],
|
||||
defaultPath: window.app.getPath('home'),
|
||||
});
|
||||
if (picked.canceled || !picked.filePath) {
|
||||
return;
|
||||
}
|
||||
// Move into `<chosen-parent>/<repo-name>`, matching the clone flow.
|
||||
const folderName = deriveRepoName(gitRepository.uri) || gitRepository._id;
|
||||
const newDirectory = window.path.join(picked.filePath, folderName);
|
||||
|
||||
setError(null);
|
||||
relocateFetcher.submit({
|
||||
gitRepositoryId: gitRepository._id,
|
||||
projectId: project._id,
|
||||
newDirectory,
|
||||
});
|
||||
};
|
||||
|
||||
const showGitRepoForm =
|
||||
storageType === 'git' &&
|
||||
@@ -388,6 +431,25 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
Open in file system
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
onPress={onRelocateRepo}
|
||||
isDisabled={isRelocating}
|
||||
className="flex items-center justify-center rounded-xs p-1 hover:bg-(--hl-xs)"
|
||||
aria-label="Move repository to another folder"
|
||||
>
|
||||
<Icon
|
||||
icon={isRelocating ? 'spinner' : 'pen-to-square'}
|
||||
className={`size-4 ${isRelocating ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
<Tooltip
|
||||
offset={8}
|
||||
className="rounded-md border border-solid border-(--hl-sm) bg-(--color-bg) px-3 py-2 text-sm text-(--color-font) shadow-lg"
|
||||
>
|
||||
Move to another folder
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -15,6 +15,44 @@ export interface ProjectData {
|
||||
credentialsId?: string;
|
||||
connectRepositoryLater?: boolean;
|
||||
selectedAuthorEmail?: string | null;
|
||||
/**
|
||||
* Optional user-chosen parent folder to clone into. When set, the repo is
|
||||
* cloned into `<cloneParentDir>/<repo-name>`; when unset, Insomnia manages the
|
||||
* location.
|
||||
*/
|
||||
cloneParentDir?: string;
|
||||
}
|
||||
|
||||
const LAST_CLONE_DIR_KEY = 'insomnia.git.lastCloneParentDir';
|
||||
|
||||
export const getLastCloneParentDir = (): string => {
|
||||
try {
|
||||
return window.localStorage.getItem(LAST_CLONE_DIR_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const setLastCloneParentDir = (dir: string): void => {
|
||||
try {
|
||||
window.localStorage.setItem(LAST_CLONE_DIR_KEY, dir);
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
/** Derive the destination folder name from a git URL (e.g. `…/foo.git` → `foo`). */
|
||||
export const deriveRepoName = (uri?: string): string => {
|
||||
if (!uri) {
|
||||
return 'repository';
|
||||
}
|
||||
const trimmed = uri.trim().replace(/[/\\]+$/, '');
|
||||
const lastSegment = trimmed.split(/[/\\:]/).pop() || '';
|
||||
const name = lastSegment.replace(/\.git$/i, '').split(/[?#]/, 1)[0].trim();
|
||||
if (!name || name === '.' || name === '..') {
|
||||
return 'repository';
|
||||
}
|
||||
return name;
|
||||
};
|
||||
|
||||
export type ProjectType = 'local' | 'remote' | 'git';
|
||||
|
||||
39
packages/insomnia/src/ui/utils/git-folder-trust.tsx
Normal file
39
packages/insomnia/src/ui/utils/git-folder-trust.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
|
||||
import { showModal } from '~/ui/components/modals';
|
||||
import { AskModal } from '~/ui/components/modals/ask-modal';
|
||||
|
||||
/**
|
||||
* Ask the user to confirm they trust a folder before opening it as a Git project.
|
||||
*
|
||||
* Opening a folder imports the API collections, environments and lint rulesets it
|
||||
* contains. Collections can carry pre-request / after-response scripts that run
|
||||
* (in Insomnia's sandbox) when requests are sent. Insomnia uses isomorphic-git
|
||||
* rather than the native `git` binary, so opening a folder never executes git
|
||||
* hooks — but a folder from an untrusted source can still bring in unwanted
|
||||
* content, so we surface a one-time trust prompt.
|
||||
*
|
||||
* Resolves `true` when the user chooses to open, `false` if they cancel or dismiss.
|
||||
*/
|
||||
export const confirmOpenFolderTrust = (folderPath: string): Promise<boolean> =>
|
||||
new Promise(resolve => {
|
||||
showModal(AskModal, {
|
||||
title: 'Do you trust this folder?',
|
||||
yesText: 'Open folder',
|
||||
noText: 'Cancel',
|
||||
message: (
|
||||
<div className="flex flex-col gap-3 text-left">
|
||||
<p>Only open folders from a source you trust.</p>
|
||||
<p className="text-sm text-(--hl)">
|
||||
Insomnia will read this folder and import any API collections, environments and lint rulesets it contains.
|
||||
Collections can include pre-request and after-response scripts that run when you send their requests.
|
||||
</p>
|
||||
<div className="rounded-xs bg-(--hl-xxs) px-2 py-1 font-mono text-xs break-all text-(--color-font)">
|
||||
{folderPath}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onDone: async (isYes: boolean) => resolve(isYes),
|
||||
onHide: () => resolve(false),
|
||||
});
|
||||
});
|
||||
14
packages/insomnia/src/ui/utils/git-repo-path.ts
Normal file
14
packages/insomnia/src/ui/utils/git-repo-path.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { GitRepository } from 'insomnia-data';
|
||||
|
||||
/**
|
||||
* Resolve a Git repository's on-disk base directory in the renderer.
|
||||
*
|
||||
* Mirrors the main-process `getRepoBaseDir` (git-service.ts): a user-chosen
|
||||
* `directory` when set, else the app-managed location.
|
||||
*/
|
||||
export const resolveGitRepoBaseDir = (gitRepository: Pick<GitRepository, '_id' | 'directory'>): string => {
|
||||
if (gitRepository.directory) {
|
||||
return gitRepository.directory;
|
||||
}
|
||||
return window.path.join(window.app.getPath('userData'), `version-control/git/${gitRepository._id}`);
|
||||
};
|
||||
@@ -2,6 +2,7 @@ interface Options {
|
||||
itemTypes?: ('file' | 'directory')[];
|
||||
extensions?: string[];
|
||||
showHiddenFiles?: boolean;
|
||||
defaultPath?: string;
|
||||
}
|
||||
|
||||
interface FileSelection {
|
||||
@@ -9,7 +10,7 @@ interface FileSelection {
|
||||
canceled: boolean;
|
||||
}
|
||||
|
||||
export const selectFileOrFolder = async ({ itemTypes, extensions, showHiddenFiles }: Options) => {
|
||||
export const selectFileOrFolder = async ({ itemTypes, extensions, showHiddenFiles, defaultPath }: Options) => {
|
||||
const types = itemTypes || ['file'];
|
||||
let title = 'Select ';
|
||||
|
||||
@@ -48,6 +49,7 @@ export const selectFileOrFolder = async ({ itemTypes, extensions, showHiddenFile
|
||||
const { canceled, filePaths } = await window.dialog.showOpenDialog({
|
||||
title,
|
||||
buttonLabel: 'Select',
|
||||
defaultPath,
|
||||
properties,
|
||||
filters: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user