From be6fc897fc462e9667164abc0c9e1b0e19c0f04b Mon Sep 17 00:00:00 2001 From: JinmingYang <2214962083@qq.com> Date: Fri, 17 Jan 2025 00:49:36 +0800 Subject: [PATCH] feat: fix vfs bug and add git projects managements settings --- package.json | 1 + pnpm-lock.yaml | 8 + src/extension/actions/apply-actions.ts | 3 +- src/extension/actions/file-actions.ts | 12 +- src/extension/actions/git-actions.ts | 43 +-- src/extension/actions/git-project-actions.ts | 221 +++++++++++++++ src/extension/actions/index.ts | 6 +- src/extension/actions/project-actions.ts | 118 ++++++++ src/extension/actions/settings-actions.ts | 39 +++ .../commands/copy-as-prompt/command.ts | 9 +- src/extension/file-utils/git.ts | 85 ++++++ src/extension/file-utils/ignore-patterns.ts | 21 +- src/extension/file-utils/paths.ts | 17 +- .../file-utils/show-continue-message.ts | 4 +- .../file-utils/tmp-file/get-tmp-file-info.ts | 7 +- src/extension/file-utils/traverse-fs.ts | 4 +- src/extension/file-utils/vfs/helpers/types.ts | 7 + src/extension/file-utils/vfs/helpers/utils.ts | 10 +- src/extension/file-utils/vfs/index.ts | 47 ++-- .../file-utils/vfs/schemes/doc-scheme.ts | 13 +- .../vfs/schemes/git-project-scheme.ts | 14 +- .../file-utils/vfs/schemes/project-scheme.ts | 13 +- .../vfs/schemes/workspace-scheme.ts | 20 +- src/shared/entities/project-entity.ts | 4 +- .../entities/setting-entity/render-options.ts | 10 + .../entities/setting-entity/setting-config.ts | 13 + .../setting-entity/setting-items-config.ts | 35 +++ .../agents/_for-migrate/grep-search-agent.ts | 6 +- .../client/fs-mention-client-plugin.tsx | 16 +- .../fs-mention-chat-strategy-provider.ts | 15 +- .../mentions/fs-mention-plugin/types.ts | 2 +- src/shared/utils/common.ts | 3 +- src/shared/utils/scheme-uri-helper.ts | 157 ++++++++++- .../git-project-card.tsx | 126 +++++++++ .../git-project-dialog.tsx | 265 ++++++++++++++++++ .../git-project-management/index.tsx | 203 ++++++++++++++ .../project-management/index.tsx | 174 ++++++++++++ .../project-management/project-card.tsx | 104 +++++++ .../project-management/project-dialog.tsx | 140 +++++++++ .../settings/setting-item-renderer.tsx | 8 + .../hooks/chat/use-files-tree-items.ts | 23 +- src/webview/utils/scheme-uri.ts | 34 +++ 42 files changed, 1931 insertions(+), 129 deletions(-) create mode 100644 src/extension/actions/git-project-actions.ts create mode 100644 src/extension/actions/project-actions.ts create mode 100644 src/extension/file-utils/git.ts create mode 100644 src/extension/file-utils/vfs/helpers/types.ts create mode 100644 src/webview/components/settings/custom-renders/git-project-management/git-project-card.tsx create mode 100644 src/webview/components/settings/custom-renders/git-project-management/git-project-dialog.tsx create mode 100644 src/webview/components/settings/custom-renders/git-project-management/index.tsx create mode 100644 src/webview/components/settings/custom-renders/project-management/index.tsx create mode 100644 src/webview/components/settings/custom-renders/project-management/project-card.tsx create mode 100644 src/webview/components/settings/custom-renders/project-management/project-dialog.tsx create mode 100644 src/webview/utils/scheme-uri.ts diff --git a/package.json b/package.json index eff13ce..d55e872 100644 --- a/package.json +++ b/package.json @@ -542,6 +542,7 @@ "comment-json": "^4.2.5", "commitizen": "^4.3.1", "cpy": "10.1.0", + "date-fns": "^4.1.0", "diff": "^7.0.0", "es-toolkit": "^1.31.0", "eslint": "^8.57.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88415ca..9d01a8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: cpy: specifier: 10.1.0 version: 10.1.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 diff: specifier: ^7.0.0 version: 7.0.0 @@ -5153,6 +5156,9 @@ packages: resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} engines: {node: '>= 0.4'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + dayjs@1.11.12: resolution: {integrity: sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==} @@ -14258,6 +14264,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.1 + date-fns@4.1.0: {} + dayjs@1.11.12: {} debug@3.2.7: diff --git a/src/extension/actions/apply-actions.ts b/src/extension/actions/apply-actions.ts index 64f68b6..89d55ca 100644 --- a/src/extension/actions/apply-actions.ts +++ b/src/extension/actions/apply-actions.ts @@ -7,6 +7,7 @@ import { HumanMessage, SystemMessage } from '@langchain/core/messages' import { ServerActionCollection } from '@shared/actions/server-action-collection' import type { ActionContext } from '@shared/actions/types' import { FeatureModelSettingKey } from '@shared/entities' +import { toUnixPath } from '@shared/utils/common' import * as vscode from 'vscode' export class ApplyActionsCollection extends ServerActionCollection { @@ -66,7 +67,7 @@ Don't reply with anything except the code. const uri = vscode.window.visibleTextEditors.find( - editor => editor.document.uri.fsPath === fullPath + editor => toUnixPath(editor.document.uri.fsPath) === fullPath )?.document.uri || vscode.Uri.file(fullPath) const document = await vscode.workspace.openTextDocument(uri) const fullRange = new vscode.Range( diff --git a/src/extension/actions/file-actions.ts b/src/extension/actions/file-actions.ts index 1d73929..accb0cb 100644 --- a/src/extension/actions/file-actions.ts +++ b/src/extension/actions/file-actions.ts @@ -9,6 +9,7 @@ import { type FolderInfo } from '@extension/file-utils/traverse-fs' import { vfs } from '@extension/file-utils/vfs' +import { workspaceSchemeHandler } from '@extension/file-utils/vfs/schemes/workspace-scheme' import { logger } from '@extension/logger' import { ServerActionCollection } from '@shared/actions/server-action-collection' import type { ActionContext } from '@shared/actions/types' @@ -175,8 +176,9 @@ export class FileActionsCollection extends ServerActionCollection { const { actionParams } = context const { schemeUris } = actionParams const finalSchemeUris = await settledPromiseResults( - schemeUris.map(schemeUri => vfs.fixSchemeUri(schemeUri)) + schemeUris.map(async schemeUri => await vfs.fixSchemeUri(schemeUri)) ) + return await traverseFileOrFolders({ type: 'file', schemeUris: finalSchemeUris, @@ -191,7 +193,7 @@ export class FileActionsCollection extends ServerActionCollection { const { actionParams } = context const { schemeUris } = actionParams const finalSchemeUris = await settledPromiseResults( - schemeUris.map(schemeUri => vfs.fixSchemeUri(schemeUri)) + schemeUris.map(async schemeUri => await vfs.fixSchemeUri(schemeUri)) ) return await traverseFileOrFolders({ type: 'folder', @@ -250,7 +252,9 @@ export class FileActionsCollection extends ServerActionCollection { d.severity === vscode.DiagnosticSeverity.Error ? 'error' : 'warning', - file: vscode.workspace.asRelativePath(document.uri), + schemeUri: workspaceSchemeHandler.createSchemeUri({ + fullPath: document.uri.fsPath + }), line: d.range.start.line + 1, column: d.range.start.character + 1 } satisfies EditorError @@ -266,7 +270,7 @@ export class FileActionsCollection extends ServerActionCollection { self.findIndex( e => e.message === error.message && - e.file === error.file && + e.schemeUri === error.schemeUri && e.line === error.line && e.column === error.column ) diff --git a/src/extension/actions/git-actions.ts b/src/extension/actions/git-actions.ts index af80656..abce1e1 100644 --- a/src/extension/actions/git-actions.ts +++ b/src/extension/actions/git-actions.ts @@ -1,5 +1,4 @@ -import type { CommandManager } from '@extension/commands/command-manager' -import type { RegisterManager } from '@extension/registers/register-manager' +import { gitUtils } from '@extension/file-utils/git' import { getWorkspaceFolder } from '@extension/utils' import { ServerActionCollection } from '@shared/actions/server-action-collection' import type { ActionContext } from '@shared/actions/types' @@ -8,20 +7,15 @@ import type { GitDiff } from '@shared/plugins/mentions/git-mention-plugin/types' import { settledPromiseResults } from '@shared/utils/common' -import simpleGit, { SimpleGit } from 'simple-git' +import { SimpleGit } from 'simple-git' export class GitActionsCollection extends ServerActionCollection { readonly categoryName = 'git' - private git: SimpleGit - - constructor( - registerManager: RegisterManager, - commandManager: CommandManager - ) { - super(registerManager, commandManager) + private async getGit(): Promise { const workspaceFolder = getWorkspaceFolder() - this.git = simpleGit(workspaceFolder.uri.fsPath) + const git = await gitUtils.createGit(workspaceFolder.uri.fsPath) + return git } async getHistoryCommits( @@ -29,11 +23,12 @@ export class GitActionsCollection extends ServerActionCollection { ): Promise { const { actionParams } = context const { maxCount = 50 } = actionParams - const log = await this.git.log({ maxCount }) + const git = await this.getGit() + const log = await git.log({ maxCount }) const commits: GitCommit[] = await settledPromiseResults( log.all.map(async commit => { - const diff = await this.git.diff([`${commit.hash}^`, commit.hash]) + const diff = await git.diff([`${commit.hash}^`, commit.hash]) return { sha: commit.hash, message: commit.message, @@ -52,13 +47,14 @@ export class GitActionsCollection extends ServerActionCollection { ): Promise { const { actionParams } = context const { file } = actionParams + const git = await this.getGit() const mainBranchName = await this.getMainBranchName() let diff: string if (file) { - diff = await this.git.diff([`origin/${mainBranchName}`, '--', file]) + diff = await git.diff([`origin/${mainBranchName}`, '--', file]) } else { - diff = await this.git.diff([`origin/${mainBranchName}`]) + diff = await git.diff([`origin/${mainBranchName}`]) } return this.parseDiff(diff) @@ -69,12 +65,13 @@ export class GitActionsCollection extends ServerActionCollection { ): Promise { const { actionParams } = context const { file } = actionParams + const git = await this.getGit() let diff: string if (file) { - diff = await this.git.diff(['HEAD', '--', file]) + diff = await git.diff(['HEAD', '--', file]) } else { - diff = await this.git.diff(['HEAD']) + diff = await git.diff(['HEAD']) } return this.parseDiff(diff) @@ -110,20 +107,24 @@ export class GitActionsCollection extends ServerActionCollection { } async getCurrentBranch(context: ActionContext<{}>): Promise { - return await this.git.revparse(['--abbrev-ref', 'HEAD']) + const git = await this.getGit() + return await git.revparse(['--abbrev-ref', 'HEAD']) } async getStatus(context: ActionContext<{}>): Promise { - return await this.git.status() + const git = await this.getGit() + return await git.status() } async getRemotes(context: ActionContext<{}>): Promise { - const remotes = await this.git.getRemotes(true) + const git = await this.getGit() + const remotes = await git.getRemotes(true) return remotes } private async getMainBranchName(): Promise { - const branches = await this.git.branch() + const git = await this.getGit() + const branches = await git.branch() const mainBranch = ['main', 'master', 'trunk', 'development'].find(branch => branches.all.includes(branch) ) diff --git a/src/extension/actions/git-project-actions.ts b/src/extension/actions/git-project-actions.ts new file mode 100644 index 0000000..bee4c60 --- /dev/null +++ b/src/extension/actions/git-project-actions.ts @@ -0,0 +1,221 @@ +import { gitUtils } from '@extension/file-utils/git' +import { vfs } from '@extension/file-utils/vfs' +import { gitProjectSchemeHandler } from '@extension/file-utils/vfs/schemes/git-project-scheme' +import { gitProjectDB } from '@extension/lowdb/git-project-db' +import { ServerActionCollection } from '@shared/actions/server-action-collection' +import type { ActionContext } from '@shared/actions/types' +import type { GitProject, GitProjectType } from '@shared/entities' +import { settledPromiseResults } from '@shared/utils/common' +import { z } from 'zod' + +// Create schema for validation +const gitProjectSchema = z.object({ + name: z + .string() + .min(1, 'Project name is required') + .refine(name => !name.includes('/') && !name.includes('\\'), { + message: 'Project name cannot contain slashes or backslashes' + }) + .refine( + async name => { + const projects = await gitProjectDB.getAll() + return !projects.some(p => p.name === name) + }, + { + message: 'Project name must be unique' + } + ), + type: z.enum(['github', 'gitlab', 'bitbucket']), + repoUrl: z.string().url('Invalid repository URL'), + description: z.string().optional() +}) + +export class GitProjectActionsCollection extends ServerActionCollection { + readonly categoryName = 'gitProject' + + private async validateProject( + data: Partial, + excludeId?: string + ): Promise { + const projects = await gitProjectDB.getAll() + const schema = gitProjectSchema.extend({ + name: z + .string() + .min(1, 'Project name is required') + .refine(name => !name.includes('/') && !name.includes('\\'), { + message: 'Project name cannot contain slashes or backslashes' + }) + .refine( + async name => + !projects.some(p => p.name === name && p.id !== excludeId), + { + message: 'Project name must be unique' + } + ) + }) + + await schema.parseAsync(data) + } + + private async cloneRepo(repoUrl: string, type: GitProjectType, name: string) { + const gitProjectSchemeUri = gitProjectSchemeHandler.createSchemeUri({ + name, + type, + relativePath: './' + }) + + // Remove existing directory if it exists + try { + await vfs.promises.rm(gitProjectSchemeUri, { + recursive: true, + force: true + }) + } catch {} + + // Ensure parent directory exists + await vfs.ensureDir( + gitProjectSchemeHandler.createSchemeUri({ + type, + name: '', + relativePath: '' + }) + ) + + // Clone repository + const gitProjectPath = await vfs.resolveFullPathProAsync( + gitProjectSchemeUri, + false + ) + const git = await gitUtils.createGit() + await git.clone(repoUrl, gitProjectPath) + } + + async getGitProjects(context: ActionContext<{}>) { + return await gitProjectDB.getAll() + } + + async addGitProject( + context: ActionContext<{ + name: string + type: GitProjectType + repoUrl: string + description: string + }> + ) { + const { actionParams } = context + const { name, type, repoUrl, description } = actionParams + const now = Date.now() + + // Validate project data + await this.validateProject({ name, type, repoUrl, description }) + + // Clone repository first + await this.cloneRepo(repoUrl, type, name) + + return await gitProjectDB.add({ + name, + type, + repoUrl, + description, + createdAt: now, + updatedAt: now + }) + } + + async updateGitProject( + context: ActionContext<{ + id: string + name: string + type: GitProjectType + repoUrl: string + description: string + }> + ) { + const { actionParams } = context + const { id, ...updates } = actionParams + + // Validate project data + await this.validateProject(updates, id) + + // Get old project data + const oldProject = (await gitProjectDB.getAll()).find( + project => project.id === id + ) + if (!oldProject) throw new Error('Project not found') + + // If repo URL changed, re-clone repository + if (oldProject && oldProject.repoUrl !== updates.repoUrl) { + await this.cloneRepo(updates.repoUrl, updates.type, updates.name) + } + + return await gitProjectDB.update(id, { + ...updates, + updatedAt: Date.now() + }) + } + + async removeGitProjects(context: ActionContext<{ ids: string[] }>) { + const { actionParams } = context + const { ids } = actionParams + + // Get projects before deletion + const projects = (await gitProjectDB.getAll()).filter(project => + ids.includes(project.id) + ) + + // Remove project directories + await settledPromiseResults( + projects.map(async project => { + if (!project) return + const schemeUri = gitProjectSchemeHandler.createSchemeUri({ + name: project.name, + type: project.type, + relativePath: './' + }) + + await vfs.promises.rm(schemeUri, { recursive: true, force: true }) + }) + ) + + await gitProjectDB.batchRemove(ids) + } + + async searchGitProjects(context: ActionContext<{ query: string }>) { + const { actionParams } = context + const { query } = actionParams + const projects = await gitProjectDB.getAll() + return projects.filter( + project => + project.name.toLowerCase().includes(query.toLowerCase()) || + project.repoUrl.toLowerCase().includes(query.toLowerCase()) + ) + } + + async refreshGitProject(context: ActionContext<{ id: string }>) { + const { actionParams } = context + const { id } = actionParams + + const project = (await gitProjectDB.getAll()).find( + project => project.id === id + ) + if (!project) throw new Error('Project not found') + + const schemeUri = gitProjectSchemeHandler.createSchemeUri({ + name: project.name, + type: project.type, + relativePath: './' + }) + const projectPath = await vfs.resolveFullPathProAsync(schemeUri, false) + + // Pull latest changes + const git = await gitUtils.createGit(projectPath) + await git.pull() + + // Update timestamp + await gitProjectDB.update(id, { + updatedAt: Date.now() + }) + + return project + } +} diff --git a/src/extension/actions/index.ts b/src/extension/actions/index.ts index b25a182..eecad78 100644 --- a/src/extension/actions/index.ts +++ b/src/extension/actions/index.ts @@ -10,7 +10,9 @@ import { CodebaseActionsCollection } from './codebase-actions' import { DocActionsCollection } from './doc-actions' import { FileActionsCollection } from './file-actions' import { GitActionsCollection } from './git-actions' +import { GitProjectActionsCollection } from './git-project-actions' import { MentionActionsCollection } from './mention-actions' +import { ProjectActionsCollection } from './project-actions' import { PromptSnippetActionsCollection } from './prompt-snippet-actions' import { SettingsActionsCollection } from './settings-actions' import { SystemActionsCollection } from './system-actions' @@ -31,7 +33,9 @@ export const serverActionCollections = [ AIModelActionsCollection, MentionActionsCollection, AgentActionsCollection, - PromptSnippetActionsCollection + PromptSnippetActionsCollection, + ProjectActionsCollection, + GitProjectActionsCollection ] as const satisfies (typeof ServerActionCollection)[] export type ServerActionCollections = typeof serverActionCollections diff --git a/src/extension/actions/project-actions.ts b/src/extension/actions/project-actions.ts new file mode 100644 index 0000000..52c2673 --- /dev/null +++ b/src/extension/actions/project-actions.ts @@ -0,0 +1,118 @@ +import { projectDB } from '@extension/lowdb/project-db' +import { ServerActionCollection } from '@shared/actions/server-action-collection' +import type { ActionContext } from '@shared/actions/types' +import type { Project } from '@shared/entities' +import { z } from 'zod' + +// Create schema for validation +const projectSchema = z.object({ + name: z + .string() + .min(1, 'Project name is required') + .refine(name => !name.includes('/') && !name.includes('\\'), { + message: 'Project name cannot contain slashes or backslashes' + }) + .refine( + async name => { + const projects = await projectDB.getAll() + return !projects.some(p => p.name === name) + }, + { + message: 'Project name must be unique' + } + ), + path: z.string().min(1, 'Project path is required'), + description: z.string().optional() +}) + +export class ProjectActionsCollection extends ServerActionCollection { + readonly categoryName = 'project' + + private async validateProject( + data: Partial, + excludeId?: string + ): Promise { + const projects = await projectDB.getAll() + const schema = projectSchema.extend({ + name: z + .string() + .min(1, 'Project name is required') + .refine(name => !name.includes('/') && !name.includes('\\'), { + message: 'Project name cannot contain slashes or backslashes' + }) + .refine( + async name => + !projects.some(p => p.name === name && p.id !== excludeId), + { + message: 'Project name must be unique' + } + ) + }) + + await schema.parseAsync(data) + } + + async getProjects(context: ActionContext<{}>) { + return await projectDB.getAll() + } + + async addProject( + context: ActionContext<{ + name: string + path: string + description: string + }> + ) { + const { actionParams } = context + const { name, path, description } = actionParams + const now = Date.now() + + // Validate project data + await this.validateProject({ name, path, description }) + + return await projectDB.add({ + name, + path, + description, + createdAt: now, + updatedAt: now + }) + } + + async updateProject( + context: ActionContext<{ + id: string + name: string + path: string + description: string + }> + ) { + const { actionParams } = context + const { id, ...updates } = actionParams + + // Validate project data + await this.validateProject(updates, id) + + return await projectDB.update(id, { + ...updates, + updatedAt: Date.now() + }) + } + + async removeProjects(context: ActionContext<{ ids: string[] }>) { + const { actionParams } = context + const { ids } = actionParams + await projectDB.batchRemove(ids) + } + + async searchProjects(context: ActionContext<{ query: string }>) { + const { actionParams } = context + const { query } = actionParams + const projects = await projectDB.getAll() + return projects.filter( + project => + project.name.toLowerCase().includes(query.toLowerCase()) || + project.path.toLowerCase().includes(query.toLowerCase()) + ) + } +} diff --git a/src/extension/actions/settings-actions.ts b/src/extension/actions/settings-actions.ts index abb1331..08bf041 100644 --- a/src/extension/actions/settings-actions.ts +++ b/src/extension/actions/settings-actions.ts @@ -1,3 +1,6 @@ +/* eslint-disable unused-imports/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { gitUtils } from '@extension/file-utils/git' import { globalSettingsDB, workspaceSettingsDB @@ -28,7 +31,10 @@ export class SettingsActionsCollection extends ServerActionCollection { ): Promise { const { actionParams } = context const { key, value } = actionParams + + await this.handleBeforeSettingChange(key, value, 'global') await globalSettingsDB.setSetting(key, value) + await this.handleAfterSettingChange(key, value, 'global') } async getAllGlobalSettings( @@ -51,7 +57,10 @@ export class SettingsActionsCollection extends ServerActionCollection { ): Promise { const { actionParams } = context const { key, value } = actionParams + + await this.handleBeforeSettingChange(key, value, 'workspace') await workspaceSettingsDB.setSetting(key, value) + await this.handleAfterSettingChange(key, value, 'workspace') } async getAllWorkspaceSettings( @@ -69,7 +78,9 @@ export class SettingsActionsCollection extends ServerActionCollection { const { settings } = actionParams for (const [key, value] of Object.entries(settings)) { + await this.handleBeforeSettingChange(key as SettingKey, value, 'global') await globalSettingsDB.setSetting(key as SettingKey, value) + await this.handleAfterSettingChange(key as SettingKey, value, 'global') } } @@ -82,7 +93,13 @@ export class SettingsActionsCollection extends ServerActionCollection { const { settings } = actionParams for (const [key, value] of Object.entries(settings)) { + await this.handleBeforeSettingChange( + key as SettingKey, + value, + 'workspace' + ) await workspaceSettingsDB.setSetting(key as SettingKey, value) + await this.handleAfterSettingChange(key as SettingKey, value, 'workspace') } } @@ -112,11 +129,14 @@ export class SettingsActionsCollection extends ServerActionCollection { for (const [key, value] of Object.entries(settings)) { const saveType = await this.getSaveType(key as SettingKey) + + await this.handleBeforeSettingChange(key as SettingKey, value, saveType) if (saveType === 'global') { await globalSettingsDB.setSetting(key as SettingKey, value) } else { await workspaceSettingsDB.setSetting(key as SettingKey, value) } + await this.handleAfterSettingChange(key as SettingKey, value, saveType) } } @@ -131,4 +151,23 @@ export class SettingsActionsCollection extends ServerActionCollection { ...workspaceSettings // Workspace settings take precedence } } + + private async handleBeforeSettingChange( + key: SettingKey, + value: SettingValue, + saveType: SettingsSaveType + ): Promise { + if (key === 'gitExecutablePath' && value) { + // validate git path + const isValid = await gitUtils.validateGitPath(value as string) + if (!isValid) throw new Error('Invalid git executable path') + gitUtils.clearCache() + } + } + + private async handleAfterSettingChange( + key: SettingKey, + value: SettingValue, + saveType: SettingsSaveType + ): Promise {} } diff --git a/src/extension/commands/copy-as-prompt/command.ts b/src/extension/commands/copy-as-prompt/command.ts index d5a0ab0..93136bc 100644 --- a/src/extension/commands/copy-as-prompt/command.ts +++ b/src/extension/commands/copy-as-prompt/command.ts @@ -1,5 +1,6 @@ import { getConfigKey } from '@extension/config' import { getFileOrFoldersPromptInfo } from '@extension/file-utils/get-fs-prompt-info' +import { workspaceSchemeHandler } from '@extension/file-utils/vfs/schemes/workspace-scheme' import { t } from '@extension/i18n' import * as vscode from 'vscode' @@ -14,9 +15,13 @@ export class CopyAsPromptCommand extends BaseCommand { const selectedItems = selectedUris?.length > 0 ? selectedUris : [uri] if (selectedItems.length === 0) throw new Error(t('error.noSelection')) - const selectedFileOrFolders = selectedItems.map(item => item.fsPath) + const selectedFileOrFolderSchemeUris = selectedItems.map(item => + workspaceSchemeHandler.createSchemeUri({ + fullPath: item.fsPath + }) + ) const { promptFullContent } = await getFileOrFoldersPromptInfo( - selectedFileOrFolders + selectedFileOrFolderSchemeUris ) const aiPrompt = await getConfigKey('aiPrompt') const finalPrompt = aiPrompt.replace('#{content}', promptFullContent) diff --git a/src/extension/file-utils/git.ts b/src/extension/file-utils/git.ts new file mode 100644 index 0000000..a5517da --- /dev/null +++ b/src/extension/file-utils/git.ts @@ -0,0 +1,85 @@ +import { exec } from 'child_process' +import { promisify } from 'util' +import { globalSettingsDB } from '@extension/lowdb/settings-db' +import simpleGit, { SimpleGit } from 'simple-git' + +const execAsync = promisify(exec) + +const which = async (command: string): Promise => { + try { + const { stdout } = await execAsync( + process.platform === 'win32' ? `where ${command}` : `which ${command}` + ) + const path = stdout.trim().split('\n')[0] + return path || null + } catch { + return null + } +} + +class GitUtils { + private static instance: GitUtils + + private cachedGitPath: string | null = null + + static getInstance(): GitUtils { + if (!GitUtils.instance) { + GitUtils.instance = new GitUtils() + } + return GitUtils.instance + } + + private async findGitExecutable(): Promise { + // Try to get from settings first + const customPath = await globalSettingsDB.getSetting('gitExecutablePath') + if (customPath) { + if (!(await this.validateGitPath(customPath))) + throw new Error('Invalid git executable path') + return customPath + } + + // Auto detect + const gitPath = await which('git') + if (!gitPath) { + throw new Error( + 'Git executable not found. Please install git or set custom path in settings.' + ) + } + if (!(await this.validateGitPath(gitPath))) { + throw new Error('Invalid git executable path') + } + + return gitPath + } + + async getGitPath(): Promise { + if (!this.cachedGitPath) { + this.cachedGitPath = await this.findGitExecutable() + } + return this.cachedGitPath + } + + async validateGitPath(path: string): Promise { + try { + const git = simpleGit({ binary: path }) + await git.version() + return true + } catch { + return false + } + } + + async createGit(cwd?: string): Promise { + const gitPath = await this.getGitPath() + return simpleGit({ + binary: gitPath, + ...(cwd ? { baseDir: cwd } : {}) + }) + } + + clearCache() { + this.cachedGitPath = null + } +} + +export const gitUtils = GitUtils.getInstance() diff --git a/src/extension/file-utils/ignore-patterns.ts b/src/extension/file-utils/ignore-patterns.ts index cad0b08..858eb1a 100644 --- a/src/extension/file-utils/ignore-patterns.ts +++ b/src/extension/file-utils/ignore-patterns.ts @@ -1,7 +1,7 @@ -import path from 'path' import { getConfigKey } from '@extension/config' import { logger } from '@extension/logger' import { toUnixPath } from '@shared/utils/common' +import { SchemeUriHelper } from '@shared/utils/scheme-uri-helper' import { glob } from 'glob' import ignore from 'ignore' import { Minimatch } from 'minimatch' @@ -29,7 +29,10 @@ export const createShouldIgnore = async ( if (respectGitIgnore) { try { - const gitignoreSchemeUri = path.join(dirSchemeUri, '.gitignore') + const gitignoreSchemeUri = SchemeUriHelper.join( + dirSchemeUri, + '.gitignore' + ) const gitIgnoreContent = await vfs.promises.readFile( gitignoreSchemeUri, 'utf-8' @@ -58,7 +61,11 @@ export const createShouldIgnore = async ( const relativePath = vfs.resolveRelativePathProSync(schemeUriOrFileFullPath) const unixRelativePath = toUnixPath(relativePath) - if (!unixRelativePath) return true + if (!unixRelativePath) return false + + if (['.', './', '..', '../', '/'].includes(unixRelativePath)) { + return false + } if (ig && ig.ignores(unixRelativePath)) { return true @@ -114,8 +121,8 @@ export const getAllValidFiles = async ( const basePath = await vfs.resolveBasePathProAsync(dirSchemeUri) return allFilesFullPaths.map(fullPath => { - const relativePath = path.relative(basePath, fullPath) - return path.join(baseUri, relativePath) + const relativePath = SchemeUriHelper.relative(basePath, fullPath) + return SchemeUriHelper.join(baseUri, relativePath) }) } @@ -173,7 +180,7 @@ export const getAllValidFolders = async ( const basePath = await vfs.resolveBasePathProAsync(dirSchemeUri) return folders.map(folder => { - const relativePath = path.relative(basePath, folder) - return path.join(baseUri, relativePath) + const relativePath = SchemeUriHelper.relative(basePath, folder) + return SchemeUriHelper.join(baseUri, relativePath) }) } diff --git a/src/extension/file-utils/paths.ts b/src/extension/file-utils/paths.ts index 5e9f498..8e520aa 100644 --- a/src/extension/file-utils/paths.ts +++ b/src/extension/file-utils/paths.ts @@ -2,6 +2,7 @@ import crypto from 'crypto' import path from 'path' import { getServerState } from '@extension/state' import { getWorkspaceFolder } from '@extension/utils' +import { toUnixPath } from '@shared/utils/common' import { vfs } from './vfs' @@ -38,11 +39,11 @@ export class AidePaths { getAideDir() { const { context } = getServerState() if (!context) throw new Error('No context found') - return context.globalStorageUri.fsPath + return toUnixPath(context.globalStorageUri.fsPath) } getNamespace() { - const workspacePath = getWorkspaceFolder().uri.fsPath + const workspacePath = toUnixPath(getWorkspaceFolder().uri.fsPath) return getSemanticHashName(path.basename(workspacePath), workspacePath) } @@ -63,7 +64,7 @@ export class AidePaths { isDirectory: boolean, ...segments: string[] ): Promise { - const fullPath = path.join(this.getAideDir(), ...segments) + const fullPath = toUnixPath(path.join(this.getAideDir(), ...segments)) return await this.ensurePath(fullPath, isDirectory) } @@ -71,10 +72,12 @@ export class AidePaths { isDirectory: boolean, ...segments: string[] ): Promise { - const fullPath = await this.joinAideGlobalPath( - isDirectory, - this.getNamespace(), - ...segments + const fullPath = toUnixPath( + await this.joinAideGlobalPath( + isDirectory, + this.getNamespace(), + ...segments + ) ) return await this.ensurePath(fullPath, isDirectory) } diff --git a/src/extension/file-utils/show-continue-message.ts b/src/extension/file-utils/show-continue-message.ts index 2cf53f3..9edf45e 100644 --- a/src/extension/file-utils/show-continue-message.ts +++ b/src/extension/file-utils/show-continue-message.ts @@ -1,5 +1,6 @@ import { t } from '@extension/i18n' import type { MaybePromise } from '@shared/types/common' +import { toUnixPath } from '@shared/utils/common' import * as vscode from 'vscode' /** @@ -25,7 +26,8 @@ export const showContinueMessage = async ({ onContinue: () => MaybePromise }) => { const tmpFileDocument = vscode.workspace.textDocuments.find( - document => document.uri.fsPath === tmpFileUri.fsPath + document => + toUnixPath(document.uri.fsPath) === toUnixPath(tmpFileUri.fsPath) ) if (!tmpFileDocument) return diff --git a/src/extension/file-utils/tmp-file/get-tmp-file-info.ts b/src/extension/file-utils/tmp-file/get-tmp-file-info.ts index 6b57c4a..95de882 100644 --- a/src/extension/file-utils/tmp-file/get-tmp-file-info.ts +++ b/src/extension/file-utils/tmp-file/get-tmp-file-info.ts @@ -1,5 +1,6 @@ import path from 'path' import { getActiveEditor } from '@extension/utils' +import { toUnixPath } from '@shared/utils/common' import * as vscode from 'vscode' import { getTmpFileUri } from './get-tmp-file-uri' @@ -23,7 +24,8 @@ export const getTmpFileInfo = async ( ): Promise => { const activeEditor = getActiveEditor() const activeFileUri = activeEditor.document.uri - const activeIsOriginalFile = originalFileUri.fsPath === activeFileUri.fsPath + const activeIsOriginalFile = + toUnixPath(originalFileUri.fsPath) === toUnixPath(activeFileUri.fsPath) let originalFileContent = '' let isSelection = false @@ -51,7 +53,8 @@ export const getTmpFileInfo = async ( languageId: originalFileLanguageId }) const tmpFileDocument = vscode.workspace.textDocuments.find( - document => document.uri.fsPath === tmpFileUri.fsPath + document => + toUnixPath(document.uri.fsPath) === toUnixPath(tmpFileUri.fsPath) ) const isTmpFileExists = !!tmpFileDocument const isTmpFileHasContent = !!tmpFileDocument?.getText() diff --git a/src/extension/file-utils/traverse-fs.ts b/src/extension/file-utils/traverse-fs.ts index d8a306d..fc1cfe5 100644 --- a/src/extension/file-utils/traverse-fs.ts +++ b/src/extension/file-utils/traverse-fs.ts @@ -107,10 +107,10 @@ const traverseOneProjectFs = async ( const getAllValidItemsWithCustomIgnore = async (schemeUri: string) => { if (type === 'folder') { - return getAllValidFolders(schemeUri, shouldIgnore) + return await getAllValidFolders(schemeUri, shouldIgnore) } if (type === 'file') { - return getAllValidFiles(schemeUri, shouldIgnore) + return await getAllValidFiles(schemeUri, shouldIgnore) } // For 'fileOrFolder' type, get both files and folders const files = await getAllValidFiles(schemeUri, shouldIgnore) diff --git a/src/extension/file-utils/vfs/helpers/types.ts b/src/extension/file-utils/vfs/helpers/types.ts new file mode 100644 index 0000000..de044af --- /dev/null +++ b/src/extension/file-utils/vfs/helpers/types.ts @@ -0,0 +1,7 @@ +export enum UriScheme { + Project = 'project', + GitProject = 'git-project', + Workspace = 'workspace', + File = 'file', + Doc = 'doc' +} diff --git a/src/extension/file-utils/vfs/helpers/utils.ts b/src/extension/file-utils/vfs/helpers/utils.ts index 4640695..9ce0b0a 100644 --- a/src/extension/file-utils/vfs/helpers/utils.ts +++ b/src/extension/file-utils/vfs/helpers/utils.ts @@ -4,13 +4,7 @@ import * as fs from 'fs' import type { IFS } from 'unionfs' import * as vscode from 'vscode' -export enum UriScheme { - Project = 'project', - GitProject = 'git-project', - Workspace = 'workspace', - File = 'file', - Doc = 'doc' -} +import type { UriScheme } from './types' // Interface for scheme handlers export interface SchemeHandler { @@ -35,7 +29,7 @@ export type NodeFS = typeof fs // Base class for scheme handlers export abstract class BaseSchemeHandler implements SchemeHandler { - public scheme: UriScheme + scheme: UriScheme constructor(scheme: UriScheme) { this.scheme = scheme diff --git a/src/extension/file-utils/vfs/index.ts b/src/extension/file-utils/vfs/index.ts index 49700ff..1506881 100644 --- a/src/extension/file-utils/vfs/index.ts +++ b/src/extension/file-utils/vfs/index.ts @@ -4,18 +4,16 @@ import path from 'path' import { t } from '@extension/i18n' import { logger } from '@extension/logger' import { getWorkspaceFolder } from '@extension/utils' -import { hasOwnProperty } from '@shared/utils/common' +import { hasOwnProperty, toUnixPath } from '@shared/utils/common' +import { SchemeUriHelper } from '@shared/utils/scheme-uri-helper' import JSONC from 'comment-json' import { Union, type IUnionFs } from 'unionfs' import * as vscode from 'vscode' import { ensureDir } from './helpers/fs-extra/ensure-dir' import { ensureFile } from './helpers/fs-extra/ensure-file' -import { - type OptimizedIFS, - type SchemeHandler, - type UriScheme -} from './helpers/utils' +import type { UriScheme } from './helpers/types' +import { type OptimizedIFS, type SchemeHandler } from './helpers/utils' import { docSchemeHandler } from './schemes/doc-scheme' import { gitProjectSchemeHandler } from './schemes/git-project-scheme' import { projectSchemeHandler } from './schemes/project-scheme' @@ -62,18 +60,16 @@ export class VirtualFileSystem implements OptimizedIFS { throwErrorIfNotFound = false ): SchemeHandler | undefined => { const stringPath = typeof path === 'string' ? path : path.toString() - const scheme = stringPath.match(/^([a-zA-Z0-9]+):/)?.[1] as - | UriScheme - | undefined + const { scheme } = SchemeUriHelper.parse(stringPath, false) - if (scheme && !this.schemeHandlerMap.has(scheme)) { + if (scheme && !this.schemeHandlerMap.has(scheme as UriScheme)) { if (throwErrorIfNotFound) { throw new Error(`No handler found for URI: ${stringPath}`) } return undefined } - return scheme ? this.schemeHandlerMap.get(scheme) : undefined + return scheme ? this.schemeHandlerMap.get(scheme as UriScheme) : undefined } private getFs = (originalPath: string | PathLike): OptimizedIFS => { @@ -119,16 +115,16 @@ export class VirtualFileSystem implements OptimizedIFS { if (returnNullIfNotExists && !isExists) return null as any - return absolutePath + return toUnixPath(absolutePath) } catch { if (returnNullIfNotExists) return null as any - return absolutePath + return toUnixPath(absolutePath) } } resolveRelativePathProSync = (originalPath: string): string => { try { - const handler = this.getSchemeHandler(originalPath, true) + const handler = this.getSchemeHandler(originalPath, false) if (handler) return handler.resolveRelativePathSync(originalPath) const workspaceFolder = getWorkspaceFolder() @@ -138,25 +134,26 @@ export class VirtualFileSystem implements OptimizedIFS { originalPath && path.isAbsolute(originalPath) ? originalPath : path.join(workspaceFolder.uri.fsPath, originalPath) + const relativePath = path.relative( workspaceFolder.uri.fsPath, absolutePath ) - return relativePath + return toUnixPath(relativePath || './') } catch (error) { logger.error( `Error resolving scheme relative file path: ${originalPath}`, error ) - return originalPath + return toUnixPath(originalPath) } } resolveBasePathProAsync = async ( originalPath: string | PathLike ): Promise => { - const defaultPath = getWorkspaceFolder().uri.fsPath + const defaultPath = toUnixPath(getWorkspaceFolder().uri.fsPath) try { const handler = this.getSchemeHandler(originalPath, true) if (handler) return handler.resolveBasePathAsync(String(originalPath)) @@ -174,10 +171,22 @@ export class VirtualFileSystem implements OptimizedIFS { } isSchemeUri = (uri: string): boolean => { - const handler = this.getSchemeHandler(uri, true) + const handler = this.getSchemeHandler(uri, false) return !!handler } + toSchemeUri = async ( + uri: string | PathLike | vscode.Uri + ): Promise => { + if (uri instanceof vscode.Uri) { + return workspaceSchemeHandler.createSchemeUri({ + fullPath: uri.fsPath + }) + } + + return await this.fixSchemeUri(String(uri)) + } + fixSchemeUri = async (uri: string): Promise => { if (!this.isSchemeUri(uri)) { const fullPath = await this.resolveFullPathProAsync(uri, false) @@ -185,7 +194,7 @@ export class VirtualFileSystem implements OptimizedIFS { fullPath }) } - return uri + return toUnixPath(uri) } writeJsonFile = async (filePath: string, data: any): Promise => { diff --git a/src/extension/file-utils/vfs/schemes/doc-scheme.ts b/src/extension/file-utils/vfs/schemes/doc-scheme.ts index a0c2011..c9b81b5 100644 --- a/src/extension/file-utils/vfs/schemes/doc-scheme.ts +++ b/src/extension/file-utils/vfs/schemes/doc-scheme.ts @@ -1,9 +1,10 @@ -import * as path from 'path' import { DocCrawler } from '@extension/chat/utils/doc-crawler' import { docSitesDB } from '@extension/lowdb/doc-sites-db' +import { toUnixPath } from '@shared/utils/common' import { SchemeUriHelper } from '@shared/utils/scheme-uri-helper' -import { BaseSchemeHandler, UriScheme } from '../helpers/utils' +import { UriScheme } from '../helpers/types' +import { BaseSchemeHandler } from '../helpers/utils' // doc:/// export class DocSchemeHandler extends BaseSchemeHandler { @@ -17,7 +18,7 @@ export class DocSchemeHandler extends BaseSchemeHandler { if (!site) throw new Error(`Site: ${siteName} not found`) const docCrawlerPath = await DocCrawler.getDocCrawlerFolderPath(site.url) - return docCrawlerPath + return toUnixPath(docCrawlerPath) } resolveBaseUriSync(uri: string): string { @@ -53,7 +54,7 @@ export class DocSchemeHandler extends BaseSchemeHandler { if (!siteName) throw new Error('Invalid doc URI: missing site name') - return relativePathParts.join('/') + return relativePathParts.join('/') || './' } async resolveRelativePathAsync(uri: string): Promise { @@ -67,13 +68,13 @@ export class DocSchemeHandler extends BaseSchemeHandler { async resolveFullPathAsync(uri: string): Promise { const basePath = await this.resolveBaseUriAsync(uri) const relativePath = this.resolveRelativePathSync(uri) - return path.join(basePath, relativePath) + return SchemeUriHelper.join(basePath, relativePath) } createSchemeUri(props: { siteName: string; relativePath: string }): string { return SchemeUriHelper.create( this.scheme, - path.join(props.siteName, props.relativePath) + SchemeUriHelper.join(props.siteName, props.relativePath) ) } } diff --git a/src/extension/file-utils/vfs/schemes/git-project-scheme.ts b/src/extension/file-utils/vfs/schemes/git-project-scheme.ts index 2494960..b24bc07 100644 --- a/src/extension/file-utils/vfs/schemes/git-project-scheme.ts +++ b/src/extension/file-utils/vfs/schemes/git-project-scheme.ts @@ -1,9 +1,9 @@ -import path from 'path' import { aidePaths } from '@extension/file-utils/paths' import type { GitProjectType } from '@shared/entities' import { SchemeUriHelper } from '@shared/utils/scheme-uri-helper' -import { BaseSchemeHandler, UriScheme } from '../helpers/utils' +import { UriScheme } from '../helpers/types' +import { BaseSchemeHandler } from '../helpers/utils' // git-project://// export class GitProjectSchemeHandler extends BaseSchemeHandler { @@ -16,7 +16,7 @@ export class GitProjectSchemeHandler extends BaseSchemeHandler { type: GitProjectType ): Promise { const gitProjectsPath = await aidePaths.getGitProjectsPath() - return path.join(gitProjectsPath, type, name) + return SchemeUriHelper.join(gitProjectsPath, type, name) } resolveBaseUriSync(uri: string): string { @@ -26,7 +26,7 @@ export class GitProjectSchemeHandler extends BaseSchemeHandler { if (!type) throw new Error('Invalid git project URI: missing type') if (!name) throw new Error('Invalid git project URI: missing project name') - return SchemeUriHelper.create(this.scheme, path.join(type, name)) + return SchemeUriHelper.create(this.scheme, SchemeUriHelper.join(type, name)) } async resolveBaseUriAsync(uri: string): Promise { @@ -58,7 +58,7 @@ export class GitProjectSchemeHandler extends BaseSchemeHandler { if (!type) throw new Error('Invalid git project URI: missing type') if (!name) throw new Error('Invalid git project URI: missing project name') - return relativePathParts.join('/') + return relativePathParts.join('/') || './' } async resolveRelativePathAsync(uri: string): Promise { @@ -72,7 +72,7 @@ export class GitProjectSchemeHandler extends BaseSchemeHandler { async resolveFullPathAsync(uri: string): Promise { const basePath = await this.resolveBasePathAsync(uri) const relativePath = this.resolveRelativePathSync(uri) - return path.join(basePath, relativePath) + return SchemeUriHelper.join(basePath, relativePath) } createSchemeUri(props: { @@ -82,7 +82,7 @@ export class GitProjectSchemeHandler extends BaseSchemeHandler { }): string { return SchemeUriHelper.create( this.scheme, - path.join(props.type, props.name, props.relativePath) + SchemeUriHelper.join(props.type, props.name, props.relativePath) ) } } diff --git a/src/extension/file-utils/vfs/schemes/project-scheme.ts b/src/extension/file-utils/vfs/schemes/project-scheme.ts index 90fa672..e755a53 100644 --- a/src/extension/file-utils/vfs/schemes/project-scheme.ts +++ b/src/extension/file-utils/vfs/schemes/project-scheme.ts @@ -1,8 +1,9 @@ -import * as path from 'path' import { projectDB } from '@extension/lowdb/project-db' +import { toUnixPath } from '@shared/utils/common' import { SchemeUriHelper } from '@shared/utils/scheme-uri-helper' -import { BaseSchemeHandler, UriScheme } from '../helpers/utils' +import { UriScheme } from '../helpers/types' +import { BaseSchemeHandler } from '../helpers/utils' // project means the project folder in the workspace // project:/// @@ -15,7 +16,7 @@ export class ProjectSchemeHandler extends BaseSchemeHandler { const projects = await projectDB.getAll() const project = projects.find(p => p.name === projectName) if (!project) throw new Error(`Project: ${projectName} not found`) - return project.projectPath + return toUnixPath(project.path) } resolveBaseUriSync(uri: string): string { @@ -54,7 +55,7 @@ export class ProjectSchemeHandler extends BaseSchemeHandler { if (!projectName) throw new Error('Invalid project URI: missing project name') - return relativePathParts.join('/') + return relativePathParts.join('/') || './' } async resolveRelativePathAsync(uri: string): Promise { @@ -68,7 +69,7 @@ export class ProjectSchemeHandler extends BaseSchemeHandler { async resolveFullPathAsync(uri: string): Promise { const basePath = await this.resolveBasePathAsync(uri) const relativePath = this.resolveRelativePathSync(uri) - return path.join(basePath, relativePath) + return SchemeUriHelper.join(basePath, relativePath) } createSchemeUri(props: { @@ -77,7 +78,7 @@ export class ProjectSchemeHandler extends BaseSchemeHandler { }): string { return SchemeUriHelper.create( this.scheme, - path.join(props.projectName, props.relativePath) + SchemeUriHelper.join(props.projectName, props.relativePath) ) } } diff --git a/src/extension/file-utils/vfs/schemes/workspace-scheme.ts b/src/extension/file-utils/vfs/schemes/workspace-scheme.ts index 97dbcd3..5dcf1e1 100644 --- a/src/extension/file-utils/vfs/schemes/workspace-scheme.ts +++ b/src/extension/file-utils/vfs/schemes/workspace-scheme.ts @@ -1,10 +1,10 @@ -import * as path from 'path' import { t } from '@extension/i18n' import { getWorkspaceFolder } from '@extension/utils' +import { toUnixPath } from '@shared/utils/common' import { SchemeUriHelper } from '@shared/utils/scheme-uri-helper' -import * as vscode from 'vscode' -import { BaseSchemeHandler, UriScheme } from '../helpers/utils' +import { UriScheme } from '../helpers/types' +import { BaseSchemeHandler } from '../helpers/utils' // workspace:// export class WorkspaceSchemeHandler extends BaseSchemeHandler { @@ -25,7 +25,7 @@ export class WorkspaceSchemeHandler extends BaseSchemeHandler { const workspaceFolder = getWorkspaceFolder() if (!workspaceFolder) throw new Error(t('error.noWorkspace')) - return workspaceFolder.uri.fsPath + return toUnixPath(workspaceFolder.uri.fsPath) } async resolveBasePathAsync(): Promise { @@ -34,7 +34,7 @@ export class WorkspaceSchemeHandler extends BaseSchemeHandler { resolveRelativePathSync(uri: string): string { const uriHelper = new SchemeUriHelper(uri) - return uriHelper.getPath() + return uriHelper.getPath() || './' } async resolveRelativePathAsync(uri: string): Promise { @@ -44,7 +44,7 @@ export class WorkspaceSchemeHandler extends BaseSchemeHandler { resolveFullPathSync(uri: string): string { const basePath = this.resolveBasePathSync() const relativePath = this.resolveRelativePathSync(uri) - return path.join(basePath, relativePath) + return SchemeUriHelper.join(basePath, relativePath) } async resolveFullPathAsync(uri: string): Promise { @@ -57,7 +57,13 @@ export class WorkspaceSchemeHandler extends BaseSchemeHandler { } if (props.fullPath) { - const relativePath = vscode.workspace.asRelativePath(props.fullPath) + const workspaceFolder = getWorkspaceFolder() + if (!workspaceFolder) throw new Error(t('error.noWorkspace')) + + const relativePath = SchemeUriHelper.relative( + workspaceFolder.uri.fsPath, + props.fullPath + ) return SchemeUriHelper.create(this.scheme, relativePath) } diff --git a/src/shared/entities/project-entity.ts b/src/shared/entities/project-entity.ts index 0653a4e..6308203 100644 --- a/src/shared/entities/project-entity.ts +++ b/src/shared/entities/project-entity.ts @@ -5,7 +5,7 @@ import { BaseEntity, type IBaseEntity } from './base-entity' export interface Project extends IBaseEntity { name: string description: string - projectPath: string + path: string createdAt: number updatedAt: number } @@ -18,7 +18,7 @@ export class ProjectEntity extends BaseEntity { id: uuidv4(), name: '', description: '', - projectPath: '', + path: '', createdAt: now, updatedAt: now, ...override diff --git a/src/shared/entities/setting-entity/render-options.ts b/src/shared/entities/setting-entity/render-options.ts index cce7638..f9aaeb3 100644 --- a/src/shared/entities/setting-entity/render-options.ts +++ b/src/shared/entities/setting-entity/render-options.ts @@ -32,10 +32,18 @@ export type PromptSnippetManagementRenderOptions = BaseRenderOptions< 'promptSnippetManagement', any > +export type ProjectManagementRenderOptions = BaseRenderOptions< + 'projectManagement', + any +> export type CodebaseIndexingRenderOptions = BaseRenderOptions< 'codebaseIndexing', any > +export type GitProjectManagementRenderOptions = BaseRenderOptions< + 'gitProjectManagement', + any +> export type RenderOptions = | InputRenderOptions @@ -49,6 +57,8 @@ export type RenderOptions = | DocIndexingRenderOptions | CodebaseIndexingRenderOptions | PromptSnippetManagementRenderOptions + | ProjectManagementRenderOptions + | GitProjectManagementRenderOptions export type RenderOptionsType = RenderOptions['type'] export type RenderOptionsMap = { [T in RenderOptionsType]: Extract diff --git a/src/shared/entities/setting-entity/setting-config.ts b/src/shared/entities/setting-entity/setting-config.ts index d54bdd3..9944f60 100644 --- a/src/shared/entities/setting-entity/setting-config.ts +++ b/src/shared/entities/setting-entity/setting-config.ts @@ -12,11 +12,14 @@ import { convertLanguagePairsConfig, docManagementConfig, expertCodeEnhancerPromptListConfig, + gitExecutablePathConfig, + gitProjectManagementConfig, ignorePatternsConfig, modelsConfig, openaiBaseUrlConfig, openaiKeyConfig, openaiModelConfig, + projectManagementConfig, promptSnippetConfig, readClipboardImageConfig, respectGitIgnoreConfig, @@ -60,6 +63,16 @@ export const settingsConfig: SettingConfig = { id: 'promptSnippets', label: 'Prompt Snippets', settings: [promptSnippetConfig] + }, + { + id: 'projectManagement', + label: 'Local Projects', + settings: [projectManagementConfig] + }, + { + id: 'gitProjectManagement', + label: 'Git Projects', + settings: [gitExecutablePathConfig, gitProjectManagementConfig] } ] }, diff --git a/src/shared/entities/setting-entity/setting-items-config.ts b/src/shared/entities/setting-entity/setting-items-config.ts index f416cc1..4448aec 100644 --- a/src/shared/entities/setting-entity/setting-items-config.ts +++ b/src/shared/entities/setting-entity/setting-items-config.ts @@ -121,6 +121,41 @@ export const promptSnippetConfig = { } } as const satisfies SettingConfigItem<'promptSnippetManagement'> +export const projectManagementConfig = { + key: 'projectManagement', + saveType: 'global', + renderOptions: { + type: 'projectManagement', + label: 'Project Management', + description: '', + defaultValue: {} + } +} as const satisfies SettingConfigItem<'projectManagement'> + +export const gitExecutablePathConfig = { + key: 'gitExecutablePath', + saveType: 'global', + renderOptions: { + type: 'input', + label: 'Git Executable Path', + description: + 'Custom git executable path. Leave empty to auto detect. If it is not set, it will use the system git path.', + placeholder: 'e.g., /usr/bin/git or C:\\Program Files\\Git\\bin\\git.exe', + defaultValue: '' + } +} as const satisfies SettingConfigItem<'input'> + +export const gitProjectManagementConfig = { + key: 'gitProjectManagement', + saveType: 'global', + renderOptions: { + type: 'gitProjectManagement', + label: 'Git Project Management', + description: '', + defaultValue: {} + } +} as const satisfies SettingConfigItem<'gitProjectManagement'> + // Tool settings export const aiPromptConfig = { key: 'aiPrompt', diff --git a/src/shared/plugins/agents/_for-migrate/grep-search-agent.ts b/src/shared/plugins/agents/_for-migrate/grep-search-agent.ts index 2a25256..a1cd766 100644 --- a/src/shared/plugins/agents/_for-migrate/grep-search-agent.ts +++ b/src/shared/plugins/agents/_for-migrate/grep-search-agent.ts @@ -2,6 +2,7 @@ import { BaseAgent } from '@extension/chat/strategies/_base/base-agent' import type { BaseGraphState } from '@extension/chat/strategies/_base/base-state' import { createShouldIgnore } from '@extension/file-utils/ignore-patterns' import { vfs } from '@extension/file-utils/vfs' +import { workspaceSchemeHandler } from '@extension/file-utils/vfs/schemes/workspace-scheme' import { getWorkspaceFolder } from '@extension/utils' import { glob } from 'glob' import { z } from 'zod' @@ -61,9 +62,12 @@ This is preferred over semantic search when we know the exact symbol/function na async execute(input: z.infer) { const workspaceFolder = getWorkspaceFolder() const workspacePath = workspaceFolder.uri.fsPath + const workspaceSchemeUri = workspaceSchemeHandler.createSchemeUri({ + relativePath: './' + }) // Create ignore function based on workspace settings - const shouldIgnore = await createShouldIgnore(workspacePath) + const shouldIgnore = await createShouldIgnore(workspaceSchemeUri) // Get all files based on include/exclude patterns const files = await glob(input.includePattern || '**/*', { diff --git a/src/shared/plugins/mentions/fs-mention-plugin/client/fs-mention-client-plugin.tsx b/src/shared/plugins/mentions/fs-mention-plugin/client/fs-mention-client-plugin.tsx index 73e1c63..65e9ef4 100644 --- a/src/shared/plugins/mentions/fs-mention-plugin/client/fs-mention-client-plugin.tsx +++ b/src/shared/plugins/mentions/fs-mention-plugin/client/fs-mention-client-plugin.tsx @@ -17,6 +17,7 @@ import { FileIcon as FileIcon2 } from '@webview/components/file-icon' import { api } from '@webview/network/actions-api' import { SearchSortStrategy, type MentionOption } from '@webview/types/chat' import { getFileNameFromPath } from '@webview/utils/path' +import { optimizeSchemeUriRender } from '@webview/utils/scheme-uri' import { ChevronRightIcon, FileIcon, FolderTreeIcon } from 'lucide-react' import { FsMentionType, type TreeInfo } from '../types' @@ -78,6 +79,7 @@ const createUseMentionOptions = const filesMentionOptions: MentionOption[] = files.map(file => { const label = getFileNameFromPath(file.schemeUri) const { path } = SchemeUriHelper.parse(file.schemeUri, false) + const schemeUriForRender = optimizeSchemeUriRender(file.schemeUri) return { id: `${FsMentionType.File}#${file.schemeUri}`, @@ -89,15 +91,16 @@ const createUseMentionOptions = itemLayoutProps: { icon: , label, - details: file.schemeUri + details: schemeUriForRender }, customRenderPreview: MentionFilePreview } satisfies MentionOption }) const foldersMentionOptions: MentionOption[] = folders.map(folder => { - const label = getFileNameFromPath(folder.schemeUri) + const label = getFileNameFromPath(folder.schemeUri) || 'ROOT' const { path } = SchemeUriHelper.parse(folder.schemeUri, false) + const schemeUriForRender = optimizeSchemeUriRender(folder.schemeUri) return { id: `${FsMentionType.Folder}#${folder.schemeUri}`, @@ -114,20 +117,21 @@ const createUseMentionOptions = className="size-4 mr-1" isFolder isOpen={false} - filePath={folder.schemeUri} + filePath={schemeUriForRender} /> ), label, - details: folder.schemeUri + details: schemeUriForRender }, customRenderPreview: MentionFolderPreview } satisfies MentionOption }) const treesMentionOptions: MentionOption[] = treesInfo.map(treeInfo => { - const label = getFileNameFromPath(treeInfo.schemeUri) + const label = getFileNameFromPath(treeInfo.schemeUri) || 'ROOT' const { path } = SchemeUriHelper.parse(treeInfo.schemeUri, false) + const schemeUriForRender = optimizeSchemeUriRender(treeInfo.schemeUri) return { id: `${FsMentionType.Tree}#${treeInfo.schemeUri}`, @@ -139,7 +143,7 @@ const createUseMentionOptions = itemLayoutProps: { icon: , label, - details: treeInfo.schemeUri + details: schemeUriForRender }, customRenderPreview: MentionTreePreview } satisfies MentionOption diff --git a/src/shared/plugins/mentions/fs-mention-plugin/server/chat-strategy/fs-mention-chat-strategy-provider.ts b/src/shared/plugins/mentions/fs-mention-plugin/server/chat-strategy/fs-mention-chat-strategy-provider.ts index 34dbf1a..7095ce5 100644 --- a/src/shared/plugins/mentions/fs-mention-plugin/server/chat-strategy/fs-mention-chat-strategy-provider.ts +++ b/src/shared/plugins/mentions/fs-mention-plugin/server/chat-strategy/fs-mention-chat-strategy-provider.ts @@ -305,20 +305,20 @@ ${CONTENT_SEPARATOR} if (!mentionState?.editorErrors?.length) return '' // Group errors by file - const errorsByFile = mentionState.editorErrors.reduce( + const fileSchemeUriErrorsMap = mentionState.editorErrors.reduce( (acc, error) => { - if (!acc[error.file]) { - acc[error.file] = [] + if (!acc[error.schemeUri]) { + acc[error.schemeUri] = [] } - acc[error.file]!.push(error) + acc[error.schemeUri]!.push(error) return acc }, {} as Record ) // Format errors grouped by file - const errorsContent = Object.entries(errorsByFile) - .map(([file, errors]) => { + const errorsContent = Object.entries(fileSchemeUriErrorsMap) + .map(([fileSchemeUri, errors]) => { const errorsList = errors .map(error => { const severity = error.severity.toUpperCase() @@ -328,7 +328,8 @@ ${CONTENT_SEPARATOR} }) .join('\n') - return `File: ${file}\n${errorsList}` + const relativePath = vfs.resolveRelativePathProSync(fileSchemeUri) + return `File: ${relativePath}\n${errorsList}` }) .join('\n\n') diff --git a/src/shared/plugins/mentions/fs-mention-plugin/types.ts b/src/shared/plugins/mentions/fs-mention-plugin/types.ts index c62d97c..20113bb 100644 --- a/src/shared/plugins/mentions/fs-mention-plugin/types.ts +++ b/src/shared/plugins/mentions/fs-mention-plugin/types.ts @@ -43,7 +43,7 @@ export interface EditorError { message: string code?: string severity: 'error' | 'warning' - file: string + schemeUri: string line: number column: number } diff --git a/src/shared/utils/common.ts b/src/shared/utils/common.ts index 6946a75..e5faa42 100644 --- a/src/shared/utils/common.ts +++ b/src/shared/utils/common.ts @@ -110,4 +110,5 @@ export const signalToController = (signal: AbortSignal) => { export const hasOwnProperty = (obj: unknown, prop: string): obj is T => Object.prototype.hasOwnProperty.call(obj, prop) -export const toUnixPath = (path: string) => path.replace(/[\\/]+/g, '/') +export const toUnixPath = (path: string) => + path ? path.replace(/[\\]+/g, '/') : '' diff --git a/src/shared/utils/scheme-uri-helper.ts b/src/shared/utils/scheme-uri-helper.ts index ba6ebb1..9483969 100644 --- a/src/shared/utils/scheme-uri-helper.ts +++ b/src/shared/utils/scheme-uri-helper.ts @@ -5,6 +5,14 @@ import { toUnixPath } from '@shared/utils/common' * Format: scheme:// */ export class SchemeUriHelper { + // Cache for parsed URIs + private static parseCache = new Map< + string, + { scheme: string | null; path: string } + >() + + private static MAX_CACHE_SIZE = 5000 + private scheme: string private path: string @@ -18,24 +26,37 @@ export class SchemeUriHelper { ): T extends true ? { scheme: string; path: string } : { scheme: string | null; path: string } { - const match = uri.match(/^([a-zA-Z0-9-]+):\/\/(.*)$/) + // Check cache first + const cached = SchemeUriHelper.parseCache.get(uri) + if (cached) { + if (throwError && !cached.scheme) { + throw new Error(`Invalid scheme URI: ${uri}`) + } + return cached as any + } + + const unixUri = toUnixPath(uri) + const match = unixUri.match(/^([a-zA-Z0-9-]+):\/\/(.*)$/) const result: { scheme: string | null; path: string } = { scheme: null, - path: uri + path: unixUri } if (!match) { - if (throwError) throw new Error(`Invalid scheme URI: ${uri}`) + if (throwError) throw new Error(`Invalid scheme URI: ${unixUri}`) + SchemeUriHelper.cacheResult(uri, result) return result as any } result.scheme = match[1] || null - result.path = match[2] || '' + // Normalize path: remove redundant slashes and dots + result.path = SchemeUriHelper.normalizePath(match[2] || '') if (!result.scheme && throwError) { - throw new Error(`Invalid scheme URI: ${uri}`) + throw new Error(`Invalid scheme URI: ${unixUri}`) } + SchemeUriHelper.cacheResult(uri, result) return result as any } @@ -43,7 +64,131 @@ export class SchemeUriHelper { * Create a new URI with the given scheme and path */ static create(scheme: string, path: string): string { - return `${scheme}://${toUnixPath(path)}` + if (!scheme) throw new Error('Scheme cannot be empty') + return `${scheme}://${SchemeUriHelper.normalizePath(path)}` + } + + /** + * Join scheme URI parts together + */ + static join(uri: string, ...paths: string[]): string { + if (!paths.length) return uri + + const { scheme, path: basePath } = SchemeUriHelper.parse(uri, false) + const normalizedPaths = paths.map(p => SchemeUriHelper.normalizePath(p)) + + // Handle absolute paths in arguments + if (normalizedPaths.some(p => p.startsWith('/'))) { + const lastAbsolutePathIndex = normalizedPaths.reduce( + (lastIndex, path, index) => (path.startsWith('/') ? index : lastIndex), + -1 + ) + const relevantPaths = normalizedPaths.slice(lastAbsolutePathIndex) + const joinedPath = SchemeUriHelper.normalizePath(relevantPaths.join('/')) + return scheme ? SchemeUriHelper.create(scheme, joinedPath) : joinedPath + } + + const joinedPath = SchemeUriHelper.normalizePath( + [basePath, normalizedPaths].filter(Boolean).join('/') + ) + return scheme ? SchemeUriHelper.create(scheme, joinedPath) : joinedPath + } + + /** + * Get relative path from one URI to another + */ + static relative(from: string, to: string): string { + const fromParsed = SchemeUriHelper.parse(from, false) + const toParsed = SchemeUriHelper.parse(to, false) + + // If schemes are different, return the full target path + if (fromParsed.scheme !== toParsed.scheme) { + return toParsed.path + } + + const fromParts = fromParsed.path.split('/').filter(Boolean) + const toParts = toParsed.path.split('/').filter(Boolean) + + // Handle empty paths + if (!fromParts.length) return toParts.join('/') + if (!toParts.length) return Array(fromParts.length).fill('..').join('/') + + // Find common prefix + let i = 0 + const minLength = Math.min(fromParts.length, toParts.length) + while (i < minLength && fromParts[i] === toParts[i]) { + i++ + } + + // Special case: if paths are identical + if (i === fromParts.length && i === toParts.length) { + return '' + } + + // Build relative path + const upCount = fromParts.length - i + const relativeParts = [...Array(upCount).fill('..'), ...toParts.slice(i)] + + return relativeParts.join('/') || '.' + } + + /** + * Get the extension of the path in a scheme URI + */ + static extname(uri: string): string { + const { path: uriPath } = SchemeUriHelper.parse(uri, false) + // Handle special cases + if (!uriPath || uriPath.endsWith('/')) return '' + + const basename = uriPath.split('/').pop() || '' + // Handle dotfiles without extension (e.g., .gitignore) + if (basename.startsWith('.') && !basename.includes('.', 1)) return '' + + const lastDotIndex = basename.lastIndexOf('.') + return lastDotIndex > 0 ? basename.slice(lastDotIndex) : '' + } + + /** + * Only for internal use, can work with scheme URI + * Normalize a path by removing redundant slashes and resolving dots + */ + private static normalizePath(path: string): string { + if (!path) return '' + + // Convert to Unix path and split + const parts = toUnixPath(path).split('/') + const result: string[] = [] + + for (const part of parts) { + if (!part || part === '.') continue + if (part === '..') { + if (result.length && result[result.length - 1] !== '..') { + result.pop() + } else { + result.push('..') + } + } else { + result.push(part) + } + } + + const normalized = result.join('/') + return path.startsWith('/') ? `/${normalized}` : normalized + } + + /** + * Cache parsed URI result and maintain cache size + */ + private static cacheResult( + uri: string, + result: { scheme: string | null; path: string } + ): void { + if (SchemeUriHelper.parseCache.size >= SchemeUriHelper.MAX_CACHE_SIZE) { + // Remove oldest entry + const firstKey = SchemeUriHelper.parseCache.keys().next().value + if (firstKey) SchemeUriHelper.parseCache.delete(firstKey) + } + SchemeUriHelper.parseCache.set(uri, result) } constructor(uri: string) { diff --git a/src/webview/components/settings/custom-renders/git-project-management/git-project-card.tsx b/src/webview/components/settings/custom-renders/git-project-management/git-project-card.tsx new file mode 100644 index 0000000..b4ab118 --- /dev/null +++ b/src/webview/components/settings/custom-renders/git-project-management/git-project-card.tsx @@ -0,0 +1,126 @@ +import { + ExternalLinkIcon, + Pencil2Icon, + ReloadIcon, + TrashIcon +} from '@radix-ui/react-icons' +import type { GitProject } from '@shared/entities' +import { AlertAction } from '@webview/components/ui/alert-action' +import { Button } from '@webview/components/ui/button' +import { Checkbox } from '@webview/components/ui/checkbox' +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from '@webview/components/ui/tooltip' +import { cn } from '@webview/utils/common' +import { formatDistanceToNow } from 'date-fns' + +interface GitProjectCardProps { + project: GitProject + onEdit: (project: GitProject) => void + onRemove: (id: string) => void + onRefresh: (id: string) => void + isSelected?: boolean + onSelect?: (selected: boolean) => void + refreshing?: boolean +} + +export const GitProjectCard = ({ + project, + onEdit, + onRemove, + onRefresh, + isSelected, + onSelect, + refreshing +}: GitProjectCardProps) => { + const renderField = (label: string, content: React.ReactNode) => ( +
+
{label}
+
{content}
+
+ ) + + return ( +
+
+
+ {onSelect && ( + + )} +

+ {project.name} +

+
+
+ + + onRemove(project.id)} + > + + +
+
+ +
+ {project.description && renderField('Description', project.description)} + {renderField('Type', project.type)} + {renderField( + 'Repository URL', + + + + + {project.repoUrl} + + )} + {renderField( + 'Last Updated', + formatDistanceToNow(project.updatedAt, { addSuffix: true }) + )} +
+
+ ) +} diff --git a/src/webview/components/settings/custom-renders/git-project-management/git-project-dialog.tsx b/src/webview/components/settings/custom-renders/git-project-management/git-project-dialog.tsx new file mode 100644 index 0000000..98788fe --- /dev/null +++ b/src/webview/components/settings/custom-renders/git-project-management/git-project-dialog.tsx @@ -0,0 +1,265 @@ +import { useEffect } from 'react' +import { zodResolver } from '@hookform/resolvers/zod' +import { ReloadIcon } from '@radix-ui/react-icons' +import type { GitProject, GitProjectType } from '@shared/entities' +import { Button } from '@webview/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@webview/components/ui/dialog' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@webview/components/ui/form' +import { Input } from '@webview/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@webview/components/ui/select' +import { Textarea } from '@webview/components/ui/textarea' +import { useForm, useWatch } from 'react-hook-form' +import * as z from 'zod' + +const gitProjectFormSchema = z.object({ + name: z.string().min(1, 'Project name is required'), + type: z.enum(['github', 'gitlab', 'bitbucket']), + repoUrl: z.string().min(1, 'Repository URL is required').url('Invalid URL'), + description: z.string().optional() +}) + +export type GitProjectFormValues = z.infer + +interface GitProjectDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + loading: boolean + project: Partial + onSave: (values: GitProjectFormValues) => void + editMode?: boolean +} + +const detectGitType = (url: string): GitProjectType | null => { + try { + const urlObj = new URL(url) + const hostname = urlObj.hostname.toLowerCase() + + if (hostname.includes('github.com')) return 'github' + if (hostname.includes('gitlab.com')) return 'gitlab' + if (hostname.includes('bitbucket.org')) return 'bitbucket' + + return null + } catch { + return null + } +} + +const detectGitInfo = (url: string) => { + try { + const urlObj = new URL(url) + const pathname = urlObj.pathname.replace(/\.git$/, '') + const pathParts = pathname.split('/') + // Get repo name from path + const repoName = pathParts[pathParts.length - 1] + // Get owner/org name + const ownerName = pathParts[pathParts.length - 2] + + return { + name: repoName, + description: `${ownerName}/${repoName}` + } + } catch { + return null + } +} + +export const GitProjectDialog = ({ + open, + onOpenChange, + loading, + project, + onSave, + editMode +}: GitProjectDialogProps) => { + const form = useForm({ + resolver: zodResolver(gitProjectFormSchema), + defaultValues: { + name: project.name || '', + type: project.type || 'github', + repoUrl: project.repoUrl || '', + description: project.description || '' + } + }) + + // Watch repoUrl changes to auto-detect type and info + const repoUrl = useWatch({ + control: form.control, + name: 'repoUrl' + }) + + useEffect(() => { + if (!repoUrl) return + + // Auto detect type + const detectedType = detectGitType(repoUrl) + if (detectedType) { + form.setValue('type', detectedType) + } + + // Auto detect name and description if not in edit mode and fields are empty + if (!editMode) { + const gitInfo = detectGitInfo(repoUrl) + if (gitInfo) { + const currentName = form.getValues('name') + const currentDesc = form.getValues('description') + + // Only set if fields are empty + if (!currentName) { + form.setValue('name', gitInfo?.name || '') + } + if (!currentDesc) { + form.setValue('description', gitInfo?.description || '') + } + } + } + }, [repoUrl, form, editMode]) + + useEffect(() => { + if (open) { + form.reset({ + name: project.name || '', + type: project.type || 'github', + repoUrl: project.repoUrl || '', + description: project.description || '' + }) + } + }, [open, project, form]) + + const onSubmit = (values: GitProjectFormValues) => { + onSave(values) + } + + // Detect if type should be disabled based on URL + const detectedType = repoUrl ? detectGitType(repoUrl) : null + const typeDisabled = Boolean(detectedType) + + return ( + + + + + {editMode ? 'Edit Git Project' : 'Add Git Project'} + + + {editMode + ? 'Edit your git project details below' + : 'Add a new git project by entering the details below'} + + + +
+ + ( + + Repository URL + + + + + + )} + /> + + ( + + Name + + + + + + )} + /> + + ( + + Type + + + + )} + /> + + ( + + Description + +