diff --git a/packages/insomnia-data/node-src/services/git-repository.ts b/packages/insomnia-data/node-src/services/git-repository.ts index a6220944e4..7bdb8d0a90 100644 --- a/packages/insomnia-data/node-src/services/git-repository.ts +++ b/packages/insomnia-data/node-src/services/git-repository.ts @@ -18,6 +18,10 @@ export async function getAllByCredentialId(credentialsId: string) { return db.find(type, { credentialsId }); } +export async function getByDirectory(directory: string) { + return db.findOne(type, { directory }); +} + export function update(repo: GitRepository, patch: Partial) { return db.docUpdate(repo, patch); } diff --git a/packages/insomnia-data/src/models/git-repository.ts b/packages/insomnia-data/src/models/git-repository.ts index f5dab78c7a..7e53110ead 100644 --- a/packages/insomnia-data/src/models/git-repository.ts +++ b/packages/insomnia-data/src/models/git-repository.ts @@ -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): model is GitRepository => model.type === type; diff --git a/packages/insomnia-smoke-test/playwright/pages/project/index.ts b/packages/insomnia-smoke-test/playwright/pages/project/index.ts index 926b1fe718..0f3a0f88fa 100644 --- a/packages/insomnia-smoke-test/playwright/pages/project/index.ts +++ b/packages/insomnia-smoke-test/playwright/pages/project/index.ts @@ -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 { 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 { + 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 { + 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 + * `/`. Requires the git test server + credential. + */ + async cloneGitProjectIntoFolder(name: string, parentFolderPath: string): Promise { + 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 { await this.sidebar.clickNewProject(); await this.page.getByRole('textbox', { name: 'Project name' }).click(); diff --git a/packages/insomnia-smoke-test/tests/smoke/git-local-repos.test.ts b/packages/insomnia-smoke-test/tests/smoke/git-local-repos.test.ts new file mode 100644 index 0000000000..e09b7ae02a --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/git-local-repos.test.ts @@ -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 /', 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(); +} diff --git a/packages/insomnia-smoke-test/tests/smoke/git-repo-relocation.test.ts b/packages/insomnia-smoke-test/tests/smoke/git-repo-relocation.test.ts new file mode 100644 index 0000000000..359aec51ff --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/git-repo-relocation.test.ts @@ -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 { + 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 { + 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(); +} diff --git a/packages/insomnia/electron-builder.config.js b/packages/insomnia/electron-builder.config.js index f3c8c9f855..43e95850c9 100644 --- a/packages/insomnia/electron-builder.config.js +++ b/packages/insomnia/electron-builder.config.js @@ -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, diff --git a/packages/insomnia/src/entry.main.ts b/packages/insomnia/src/entry.main.ts index dc977c9f5d..7af48ab595 100644 --- a/packages/insomnia/src/entry.main.ts +++ b/packages/insomnia/src/entry.main.ts @@ -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 => { + 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); }); diff --git a/packages/insomnia/src/entry.preload.ts b/packages/insomnia/src/entry.preload.ts index 892e078526..4751b4a337 100644 --- a/packages/insomnia/src/entry.preload.ts +++ b/packages/insomnia/src/entry.preload.ts @@ -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), diff --git a/packages/insomnia/src/main/git-service.ts b/packages/insomnia/src/main/git-service.ts index 4be801a09f..9904d12388 100644 --- a/packages/insomnia/src/main/git-service.ts +++ b/packages/insomnia/src/main/git-service.ts @@ -387,32 +387,80 @@ async function assertBranchOnOrigin(context: string): Promise { } /** - * 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 { + 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) { + 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 = {}; @@ -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 { 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 { 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('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[0]) => cloneGitRepoAction(options), ); + ipcMainHandle('git.openGitRepo', (_, options: Parameters[0]) => openGitRepoAction(options)); + ipcMainHandle('git.checkGitRepoDirectory', (_, options: Parameters[0]) => + checkGitRepoDirectoryAction(options), + ); + ipcMainHandle('git.cleanupGitRepoStorage', (_, options: Parameters[0]) => + cleanupGitRepoStorageAction(options), + ); + ipcMainHandle('git.relocateGitRepo', (_, options: Parameters[0]) => + relocateGitRepoAction(options), + ); ipcMainHandle('git.initGitRepoClone', (_, options: Parameters[0]) => initGitRepoCloneAction(options), ); diff --git a/packages/insomnia/src/main/ipc/electron.ts b/packages/insomnia/src/main/ipc/electron.ts index 32f72e2f14..554b2bdaca 100644 --- a/packages/insomnia/src/main/ipc/electron.ts +++ b/packages/insomnia/src/main/ipc/electron.ts @@ -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' diff --git a/packages/insomnia/src/main/window-utils.ts b/packages/insomnia/src/main/window-utils.ts index 5c5bc0d8af..2057d0071f 100644 --- a/packages/insomnia/src/main/window-utils.ts +++ b/packages/insomnia/src/main/window-utils.ts @@ -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 = { diff --git a/packages/insomnia/src/root.tsx b/packages/insomnia/src/root.tsx index 067ade135d..28ac3d68ae 100644 --- a/packages/insomnia/src/root.tsx +++ b/packages/insomnia/src/root.tsx @@ -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 diff --git a/packages/insomnia/src/routes/git.relocate.tsx b/packages/insomnia/src/routes/git.relocate.tsx new file mode 100644 index 0000000000..d656533235 --- /dev/null +++ b/packages/insomnia/src/routes/git.relocate.tsx @@ -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, +); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx index 9d89712e1b..b61d6cf204 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.delete.tsx @@ -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); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx index a24db01d92..73497bb066 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.update.tsx @@ -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); diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx index 3604976cd8..610e5fcc70 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.spec.tsx @@ -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. diff --git a/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx b/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx index e7cafb5022..92e0942dce 100644 --- a/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx +++ b/packages/insomnia/src/routes/organization.$organizationId.project.new.tsx @@ -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) { diff --git a/packages/insomnia/src/sync/git/repo-file-watcher.ts b/packages/insomnia/src/sync/git/repo-file-watcher.ts index e34525481f..2002964809 100644 --- a/packages/insomnia/src/sync/git/repo-file-watcher.ts +++ b/packages/insomnia/src/sync/git/repo-file-watcher.ts @@ -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 { + // 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 { + 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 { const result: string[] = []; diff --git a/packages/insomnia/src/ui/components/base/middle-truncate.tsx b/packages/insomnia/src/ui/components/base/middle-truncate.tsx new file mode 100644 index 0000000000..e2fdcf2d29 --- /dev/null +++ b/packages/insomnia/src/ui/components/base/middle-truncate.tsx @@ -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 = ({ 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 ( +
+ {head} + {tail && {tail}} +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx index 130ba7dd09..bd4c60b7b3 100644 --- a/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/git-project-sync-dropdown.tsx @@ -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 = ({ 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; diff --git a/packages/insomnia/src/ui/components/git-credentials/git-credential-select.tsx b/packages/insomnia/src/ui/components/git-credentials/git-credential-select.tsx new file mode 100644 index 0000000000..0ab6935c6f --- /dev/null +++ b/packages/insomnia/src/ui/components/git-credentials/git-credential-select.tsx @@ -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 = ({ + 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 ( + + ); +}; diff --git a/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx b/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx index 8d021a8a1c..8779795e93 100644 --- a/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-project-staging-modal.tsx @@ -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 = ({ const [operationError, setOperationError] = useState(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(() => { diff --git a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/git-repository-settings-modal.tsx b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/git-repository-settings-modal.tsx index aefddb9441..93079eeb9c 100644 --- a/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/git-repository-settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/git-repository-settings-modal/git-repository-settings-modal.tsx @@ -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 ( @@ -61,10 +63,23 @@ export const GitRepositorySettingsModal = ({ )} {authorEmail && (
-
Author Email
+
Author Email
{authorEmail}
)} +
+
Local folder
+
+ {repoBaseDir} + +
+
= ({ storageRules }) => { const { credentials, providers } = useGitCredentials(); return ( -
-
-

Welcome to your organization!

- Create a new project to get started +
+
+
+

Welcome to your organization!

+ Create a new project to get started +
+
-
); }; diff --git a/packages/insomnia/src/ui/components/project/git-repo-form.tsx b/packages/insomnia/src/ui/components/project/git-repo-form.tsx index dc905c6b4b..4b63dfb865 100644 --- a/packages/insomnia/src/ui/components/project/git-repo-form.tsx +++ b/packages/insomnia/src/ui/components/project/git-repo-form.tsx @@ -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 = ({ }} defaultSelectedKey={credentials?.[0]?._id} > - -
+ +
+ +
+ {projectData.cloneParentDir ? ( + + ) : ( +
+ Managed by Insomnia (default location) +
+ )} + + {projectData.cloneParentDir && ( + + )} +
+
)} diff --git a/packages/insomnia/src/ui/components/project/project-create-form.tsx b/packages/insomnia/src/ui/components/project/project-create-form.tsx index 83d18ecd92..92265060d3 100644 --- a/packages/insomnia/src/ui/components/project/project-create-form.tsx +++ b/packages/insomnia/src/ui/components/project/project-create-form.tsx @@ -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 = ({ onDirtyChange, }) => { const { organizationId } = useParams() as { organizationId: string }; + const navigate = useNavigate(); const isGitSyncEnabled = useIsGitSyncEnabled(organizationId); @@ -52,12 +57,29 @@ export const ProjectCreateForm: FC = ({ const [error, setError] = useState(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({ 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 = ({ } }, [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 + // `/`, 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 = ({ storageRules={storageRules} /> {storageType === 'git' && isGitSyncEnabled && ( - + <> +
+ {(['clone', 'open'] as const).map(mode => ( + + ))} +
+ + {gitMode === 'clone' ? ( + + ) : ( +
+ +

+ Insomnia will open this folder as a Git project. If it isn't a git repository yet, one will be + initialized. +

+
+ {openExistingDir ? ( + + ) : ( +
+ No folder selected +
+ )} + +
+ {existingFolderProject && ( +
+ + A project ("{existingFolderProject.name}") is already connected to this folder. Please select + another folder, or open the existing project. + + +
+ )} + setProjectData(prev => ({ ...prev, credentialsId }))} + label="Git credentials" + /> +
+ )} + )}
@@ -176,14 +320,18 @@ export const ProjectCreateForm: FC = ({ Cancel )} - {storageType !== 'git' || projectData.connectRepositoryLater ? ( + {storageType !== 'git' || projectData.connectRepositoryLater || isGitOpen ? ( ) : ( + + Move to another folder + +
diff --git a/packages/insomnia/src/ui/components/project/utils.tsx b/packages/insomnia/src/ui/components/project/utils.tsx index 4304e644ff..a48aaff3b5 100644 --- a/packages/insomnia/src/ui/components/project/utils.tsx +++ b/packages/insomnia/src/ui/components/project/utils.tsx @@ -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 `/`; 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'; diff --git a/packages/insomnia/src/ui/utils/git-folder-trust.tsx b/packages/insomnia/src/ui/utils/git-folder-trust.tsx new file mode 100644 index 0000000000..7ac162abbe --- /dev/null +++ b/packages/insomnia/src/ui/utils/git-folder-trust.tsx @@ -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 => + new Promise(resolve => { + showModal(AskModal, { + title: 'Do you trust this folder?', + yesText: 'Open folder', + noText: 'Cancel', + message: ( +
+

Only open folders from a source you trust.

+

+ 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. +

+
+ {folderPath} +
+
+ ), + onDone: async (isYes: boolean) => resolve(isYes), + onHide: () => resolve(false), + }); + }); diff --git a/packages/insomnia/src/ui/utils/git-repo-path.ts b/packages/insomnia/src/ui/utils/git-repo-path.ts new file mode 100644 index 0000000000..b3357f9d16 --- /dev/null +++ b/packages/insomnia/src/ui/utils/git-repo-path.ts @@ -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): string => { + if (gitRepository.directory) { + return gitRepository.directory; + } + return window.path.join(window.app.getPath('userData'), `version-control/git/${gitRepository._id}`); +}; diff --git a/packages/insomnia/src/ui/utils/select-file-or-folder.ts b/packages/insomnia/src/ui/utils/select-file-or-folder.ts index 88b2df4086..2aa40996e1 100644 --- a/packages/insomnia/src/ui/utils/select-file-or-folder.ts +++ b/packages/insomnia/src/ui/utils/select-file-or-folder.ts @@ -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: [ {