From ac7fce427d94a474c1241fbff8e66e332ef6b489 Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Thu, 4 Jun 2026 12:29:22 +0700 Subject: [PATCH 01/20] feat: add clean script --- package.json | 3 ++- scripts/clean.js | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 scripts/clean.js diff --git a/package.json b/package.json index a3b79c4..3b01350 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ }, "scripts": { "dev": "node esbuild.config.mjs --serve", - "build": "tsc --noEmit && node esbuild.config.mjs" + "build": "tsc --noEmit && node esbuild.config.mjs", + "clean": "node scripts/clean.js" }, "browserslist": [ "> 0.25%, not dead" diff --git a/scripts/clean.js b/scripts/clean.js new file mode 100644 index 0000000..69c5fbf --- /dev/null +++ b/scripts/clean.js @@ -0,0 +1,13 @@ +const fs = require("fs"); +const path = require("path"); + +const targets = ["dist/main.js", "dist/main.css", "plugin.zip", "dist.zip"]; + +for (const target of targets) { + const targetPath = path.join(process.cwd(), target); + if (fs.existsSync(targetPath)) { + fs.rmSync(targetPath, { recursive: true, force: true }); + } +} + +console.log("Clean completed."); From 0cab570ed3db626712a746cfea7893fd08121f1d Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Thu, 4 Jun 2026 15:06:30 +0700 Subject: [PATCH 02/20] feat: refactor logging colors to use Color constants --- src/base/colors.ts | 9 +++++++++ src/git/logger.ts | 24 ++++++++---------------- 2 files changed, 17 insertions(+), 16 deletions(-) create mode 100644 src/base/colors.ts diff --git a/src/base/colors.ts b/src/base/colors.ts new file mode 100644 index 0000000..a4b1380 --- /dev/null +++ b/src/base/colors.ts @@ -0,0 +1,9 @@ +export const Color = { + reset: '\x1b[0m', + green: '\x1b[38;2;106;153;85m', // #6a9955 + cyan: '\x1b[38;2;78;201;176m', // #4ec9b0 (cyan) + orange: '\x1b[38;2;206;145;120m', // #ce9178 (orange) + red: '\x1b[38;2;244;71;71m', // #f44747 (red), + magenta: '\x1b[35m', + default: '\x1b[37m', // #ffffff (white) +} as const; \ No newline at end of file diff --git a/src/git/logger.ts b/src/git/logger.ts index 031fd49..d72767f 100644 --- a/src/git/logger.ts +++ b/src/git/logger.ts @@ -1,14 +1,6 @@ -const terminal = acode.require('terminal'); +import { Color } from "../base/colors"; -const COLORS = { - RESET: '\x1b[0m', - TIMESTAMP: '\x1b[38;2;106;153;85m', // #6a9955 - INFO: '\x1b[38;2;78;201;176m', // #4ec9b0 (cyan) - WARN: '\x1b[38;2;206;145;120m', // #ce9178 (orange) - ERROR: '\x1b[38;2;244;71;71m', // #f44747 (red), - DEBUG: '\x1b[35m', // Magenta - DEFAULT: '\x1b[37m', // white -} as const; +const terminal = acode.require('terminal'); enum LogLevel { INFO = 'INFO', @@ -50,22 +42,22 @@ export class LogOutputChannel { private _getLevelColor(level: LogLevel): string { switch (level) { case LogLevel.INFO: - return COLORS.INFO; + return Color.cyan; case LogLevel.WARN: - return COLORS.WARN; + return Color.orange; case LogLevel.ERROR: - return COLORS.ERROR; + return Color.red; case LogLevel.DEBUG: - return COLORS.DEBUG; + return Color.magenta; default: - return COLORS.DEFAULT; + return Color.default; } } private _formatMessage(message: string, level: LogLevel): string { const timestamp = this._getTimestamp(); const levelColor = this._getLevelColor(level); - return `${COLORS.TIMESTAMP}[${timestamp}]${COLORS.RESET} ${levelColor}[${level}]${COLORS.RESET} ${COLORS.DEFAULT}${message}${COLORS.RESET}`; + return `${Color.green}[${timestamp}]${Color.reset} ${levelColor}[${level}]${Color.reset} ${Color.default}${message}${Color.reset}`; } private _writeToTerminal(message: string): void { From c4e457903a968ccfcefcffb4ae7352d30f3c356b Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Thu, 4 Jun 2026 15:29:44 +0700 Subject: [PATCH 03/20] fix: add sidebar toggle in showOutput method --- src/git/commands.ts | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/git/commands.ts b/src/git/commands.ts index f276fac..3b8c7a2 100644 --- a/src/git/commands.ts +++ b/src/git/commands.ts @@ -2356,22 +2356,22 @@ export class CommandCenter { resourceStates = resourceStates.filter(s => !!s); if (resourceStates.length === 0) { - const resource = this.getSCMResource(); + const resource = this.getSCMResource(); - if (!resource) { - return; - } + if (!resource) { + return; + } - resourceStates = [resource]; - } + resourceStates = [resource]; + } const resources = resourceStates - .filter(s => s instanceof Resource) - .map(r => r.resourceUri); + .filter(s => s instanceof Resource) + .map(r => r.resourceUri); - if (!resources.length) { - return; - } + if (!resources.length) { + return; + } await this.runByRepository(resources, async (repository, resources) => repository.ignore(resources)); } @@ -2660,8 +2660,11 @@ export class CommandCenter { } @command('Show Output') - showOutput(): void { - this.logger.show(); + async showOutput(): Promise { + if (localStorage.sidebarShown === '1') { + acode.exec('toggle-sidebar'); + } + await this.logger.show(); } @command('Clear Git Output') From 423efff894074ae37ecad8946046a7a4d1b640b7 Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Fri, 5 Jun 2026 09:04:58 +0700 Subject: [PATCH 04/20] feat: implement worktree management functionality --- src/git/api/git.d.ts | 8 ++ src/git/commands.ts | 168 +++++++++++++++++++++++++++++++++++++++++- src/git/git.ts | 103 +++++++++++++++++++++++++- src/git/operation.ts | 2 +- src/git/repository.ts | 50 +++++++++++-- src/main.ts | 15 ++++ 6 files changed, 337 insertions(+), 9 deletions(-) diff --git a/src/git/api/git.d.ts b/src/git/api/git.d.ts index 5d147ef..ee99d9e 100644 --- a/src/git/api/git.d.ts +++ b/src/git/api/git.d.ts @@ -77,6 +77,14 @@ export interface Remote { readonly isReadOnly: boolean; } +export interface Worktree { + readonly name: string; + readonly path: string; + readonly ref: string; + readonly main: boolean; + readonly detached: boolean; +} + export const enum Status { INDEX_MODIFIED, INDEX_ADDED, diff --git a/src/git/commands.ts b/src/git/commands.ts index 3b8c7a2..306c588 100644 --- a/src/git/commands.ts +++ b/src/git/commands.ts @@ -6,7 +6,7 @@ import { ApiRepository } from "./api/api1"; import { Branch, CommitOptions, ForcePushMode, GitErrorCodes, Ref, RefType, Remote, RemoteSourcePublisher, Status } from "./api/git"; import { item, showDialogMessage } from "./dialog"; import { UnifiedDiff } from "./diff"; -import { Git, Stash } from "./git"; +import { Git, GitError, Stash } from "./git"; import { getInputHintResult, HintItem, InputHint, showInputHints } from "./hints"; import { LogOutputChannel } from "./logger"; import { Model } from "./model"; @@ -1925,13 +1925,177 @@ export class CommandCenter { : remoteTags.map(ref => new TagDeleteItem(ref, commitShortHashLength)); } - const choice = await showInputHints(tagHints(), { placeholder: 'Select a tag to delete' }); + const choice = await showInputHints(tagHints(), { placeholder: 'Select a tag to delete' }); if (choice instanceof TagDeleteItem) { await choice.run(repository); } } + @command('Create Worktree...', { repository: true }) + async createWorktree(repository?: Repository): Promise { + if (!repository) { + return; + } + + await this._createWorktree(repository); + } + + async _createWorktree(repository: Repository): Promise { + const gitConfig = config.get('vcgit')!; + const branchPrefix = gitConfig.branchPrefix; + + // Get commitish and branch for the new worktree + const worktreeDetails = await this.getWorktreeCommitishAndBranch(repository); + if (!worktreeDetails) { + return; + } + + const { commitish, branch } = worktreeDetails; + const worktreeName = ((branch ?? commitish).startsWith(branchPrefix) + ? (branch ?? commitish).substring(branchPrefix.length).replace(/\//g, '-') + : (branch ?? commitish).replace(/\//g, '-')); + + // Get path for the new worktree + const worktreePath = await this.getWorktreePath(repository, worktreeName); + if (!worktreePath) { + return; + } + + try { + await repository.createWorktree({ path: worktreePath, branch, commitish: commitish }); + } catch (err) { + if (err instanceof GitError && err.gitErrorCode === GitErrorCodes.WorktreeAlreadyExists) { + await this.handleWorktreeAlreadyExists(err); + } else if (err instanceof GitError && err.gitErrorCode === GitErrorCodes.WorktreeBranchAlreadyUsed) { + await this.handleWorktreeBranchAlreadyUsed(err); + } else { + throw err; + } + } + } + + private async getWorktreeCommitishAndBranch(repository: Repository): Promise<{ commitish: string; branch: string | undefined } | undefined> { + const gitConfig = config.get('vcgit')!; + const showRefDetails = gitConfig.showReferenceDetails; + + const createBranch = new CreateBranchItem(); + const getBranchHints = async () => { + const refs = await repository.getRefs({ includeCommitDetails: showRefDetails }); + const itemsProcessor = new RefItemsProcessor(repository, [ + new RefProcessor(RefType.Head), + new RefProcessor(RefType.RemoteHead), + new RefProcessor(RefType.Tag) + ]); + const branchItems = itemsProcessor.processRefs(refs); + return [createBranch, ...branchItems]; + } + + const placeholder = 'Select a branch or tag to create the new worktree from'; + const choice = await showInputHints(getBranchHints(), { placeholder }); + + if (!choice) { + return undefined; + } + + if (choice === createBranch) { + // Create new branch + const branch = await this.promptForBranchName(repository); + if (!branch) { + return undefined; + } + + return { commitish: 'HEAD', branch }; + } else { + // Existing reference + if (!(choice instanceof RefItem) || !choice.refName) { + return undefined; + } + + if (choice.refName === repository.HEAD?.name) { + const message = `Branch "${choice.refName}" is already checked out in the current repository.`; + const createBranch = item('Create New Branch'); + const result = await showDialogMessage('WARNING', message, createBranch); + + if (result === createBranch) { + const branch = await this.promptForBranchName(repository); + if (!branch) { + return undefined; + } + + return { commitish: 'HEAD', branch }; + } else { + return undefined; + } + } else { + // Check whether the selected branch is checked out in an existing worktree + const worktree = repository.worktrees.find(worktree => worktree.ref === choice.refId); + if (worktree) { + const message = `Branch "${choice.refName}" is already checked out in the worktree at "${worktree.path}".`; + await this.handleWorktreeConflict(worktree.path, message); + return; + } + return { commitish: choice.refName, branch: undefined }; + } + } + } + + private async getWorktreePath(_repository: Repository, worktreeName: string): Promise { + const result = await fileBrowser('folder', 'Select as Worktree Destination', true); + return Url.join(uriToPath(result.url), worktreeName); + } + + private async handleWorktreeBranchAlreadyUsed(err: GitError): Promise { + const match = err.stderr?.match(/fatal: '([^']+)' is already used by worktree at '([^']+)'/); + + if (!match) { + return; + } + + const [, branch, path] = match; + const message = `Branch "${branch}" is already checked out in the worktree at "${path}".`; + await this.handleWorktreeConflict(path, message); + } + + private async handleWorktreeAlreadyExists(err: GitError): Promise { + const match = err.stderr?.match(/fatal: '([^']+)'/); + + if (!match) { + return; + } + + const [, path] = match; + const message = `A worktree already exists at "${path}".`; + await this.handleWorktreeConflict(path, message); + } + + private async handleWorktreeConflict(path: string, message: string): Promise { + await this.model.openRepository(path, true); + + const worktreeRepository = this.model.getRepository(path); + + if (!worktreeRepository) { + return; + } + + const openWorktree = item('Open Worktree in Current Window'); + const choice = await showDialogMessage('WARNING', message, openWorktree); + + if (choice === openWorktree) { + this.openWorktreeInCurrentWindow(worktreeRepository); + } + } + + @command('Open Worktree') + openWorktreeInCurrentWindow(repository: Repository): void { + if (!repository) { + return; + } + + const uri = toFileUrl(repository.root); + openFolder(uri, { name: Url.basename(uri)! }); + } + @command('Delete Remote Tag...', { repository: true }) async deleteRemoteTag(repository: Repository): Promise { const gitConfig = config.get('vcgit')!; diff --git a/src/git/git.ts b/src/git/git.ts index f51471f..54ba6a6 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -3,7 +3,7 @@ import { Disposable, IDisposable } from "../base/disposable"; import { Emitter, Event } from "../base/event"; import * as process from '../base/executor'; import { uriToPath } from "../base/uri"; -import { Commit as ApiCommit, RefQuery as ApiRefQuery, Branch, Change, CommitOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status } from "./api/git"; +import { Commit as ApiCommit, RefQuery as ApiRefQuery, Worktree as ApiWorktree, Branch, Change, CommitOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status } from "./api/git"; import { LogOutputChannel } from "./logger"; import { assign, groupBy, isAbsolute, isDescendant, Limiter, Mutable, pathEquals, relativePath, splitInChunks, toFullPath, Versions } from "./utils"; @@ -21,6 +21,7 @@ export interface IDotGit { readonly path: string; readonly commonPath?: string; readonly superProjectPath?: string; + readonly isBare: boolean; } export interface IFileStatus { @@ -322,7 +323,13 @@ export class Git { } } + const rawPath = Url.join(`file://${commonDotGitPath ?? dotGitPath}`, 'config'); + const raw = await fs(rawPath).readFile('utf-8'); + const coreSections = GitConfigParser.parse(raw).find(s => s.name === 'core'); + const isBare = coreSections?.properties['bare'] === 'true'; + return { + isBare, path: dotGitPath, commonPath: commonDotGitPath !== dotGitPath ? commonDotGitPath : undefined, superProjectPath: superProjectPath ? superProjectPath : undefined @@ -854,6 +861,10 @@ export interface PullOptions { readonly autoStash?: boolean; } +export interface Worktree extends ApiWorktree { + readonly commitDetails?: ApiCommit; +} + export class Repository { private _isUsingRefTable = false; @@ -1372,6 +1383,22 @@ export class Repository { await this.exec(args); } + async addWorktree(options: { path: string; commitish: string; branch?: string; noTrack?: boolean }): Promise { + const args = ['worktree', 'add']; + + if (options.branch) { + args.push('-b', options.branch); + } + + if (options.noTrack) { + args.push('--no-track'); + } + + args.push(options.path, options.commitish); + + await this.exec(args); + } + async reset(treeish: string, hard: boolean = false): Promise { const args = ['reset', hard ? '--hard' : '--soft', treeish]; await this.exec(args); @@ -1946,6 +1973,80 @@ export class Repository { return parseGitStashes(result.stdout.trim()); } + async getWorktrees(): Promise { + return await this.getWorktreesFS(); + } + + private async getWorktreesFS(): Promise { + const result: Worktree[] = []; + const mainRepositoryPath = this.dotGit.commonPath ?? this.dotGit.path; + + try { + if (!this.dotGit.isBare) { + // Add main worktree for a non-bare repository + const headPath = Url.join(mainRepositoryPath, 'HEAD'); + const headContent = (await fs(`file://${headPath}`).readFile('utf-8')).trim(); + + const mainRepositoryWorktreeName = Url.basename(Url.dirname(mainRepositoryPath))!; + + result.push({ + name: mainRepositoryWorktreeName, + path: toFullPath(Url.dirname(mainRepositoryPath)), + ref: headContent.replace(/^ref: /, ''), + detached: !headContent.startsWith('ref: '), + main: true + } satisfies Worktree); + } + + // List all worktree folder names + const worktreesPath = Url.join(mainRepositoryPath, 'worktrees'); + const dirents = await fs(`file://${worktreesPath}`).lsDir(); + + for (const dirent of dirents) { + if (!dirent.isDirectory) { + continue; + } + + try { + const headPath = Url.join(worktreesPath, dirent.name, 'HEAD'); + const headContent = (await fs(`file://${headPath}`).readFile('utf-8')).trim(); + + const gitdirPath = Url.join(worktreesPath, dirent.name, 'gitdir'); + const gitdirContent = (await fs(`file://${gitdirPath}`).readFile('utf-8')).trim(); + + result.push({ + name: dirent.name, + // Remove '/.git' suffix + path: toFullPath(gitdirContent.replace(/\/.git.*$/, '')), + // Remove 'ref: ' prefix + ref: headContent.replace(/^ref: /, ''), + // Detached if HEAD does not start with 'ref: ' + detached: !headContent.startsWith('ref: '), + main: false + }); + } catch (err) { + if (/ENOENT/.test(err.message) || /Path not found/.test(err.message)) { + continue; + } + + throw err; + } + } + + return result; + } catch (err) { + if ( + /ENOENT/.test(err.message) || + /ENOTDIR/.test(err.message) || + /Path not found/.test(err.message) + ) { + return result; + } + + throw err; + } + } + async getBranch(name: string): Promise { if (name === 'HEAD') { return this.getHEAD(); diff --git a/src/git/operation.ts b/src/git/operation.ts index 5784430..3d55ccb 100644 --- a/src/git/operation.ts +++ b/src/git/operation.ts @@ -194,7 +194,7 @@ export const Operation = { SubmoduleUpdate: { kind: OperationKind.SubmoduleUpdate, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as SubmoduleUpdateOperation, Sync: { kind: OperationKind.Sync, blocking: true, readOnly: false, remote: true, retry: true, showProgress: true } as SyncOperation, Tag: { kind: OperationKind.Tag, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as TagOperation, - Worktree: { kind: OperationKind.Worktree, blocking: false, readOnly: false, remote: false, retry: false, showProgress: true } as WorktreeOperation + Worktree: (readOnly: boolean) => ({ kind: OperationKind.Worktree, blocking: false, readOnly, remote: false, retry: false, showProgress: true } as WorktreeOperation) }; export interface OperationResult { diff --git a/src/git/repository.ts b/src/git/repository.ts index 4a1bd5d..9a4e45a 100644 --- a/src/git/repository.ts +++ b/src/git/repository.ts @@ -1,5 +1,6 @@ import { App } from "../base/app"; import { config } from "../base/config"; +import { FileDecoration } from "../base/decorationService"; import { debounce, memoize, throttle } from "../base/decorators"; import { Disposable, IDisposable } from "../base/disposable"; import { Emitter, Event } from "../base/event"; @@ -11,15 +12,14 @@ import { CommandActions } from "./actions"; import { ApiRepository } from "./api/api1"; import { Branch, BranchQuery, Change, Commit, CommitOptions, FetchOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status } from "./api/git"; import { AutoFetcher } from "./autofetch"; -import { FileDecoration } from "../base/decorationService"; -import { Repository as BaseRepository, GitError, LsTreeElement, PullOptions, RefQuery, Stash, Submodule } from "./git"; +import { Repository as BaseRepository, GitError, LsTreeElement, PullOptions, RefQuery, Stash, Submodule, Worktree } from "./git"; import { LogOutputChannel } from "./logger"; import { Operation, OperationKind, OperationManager, OperationResult } from "./operation"; import { IPushErrorHandlerRegistry } from "./pushError"; import { IRemoteSourcePublisherRegistry } from "./remotePublisher"; import { toGitUri } from "./uri"; -import { find, getCommitShortHash, isDescendant, relativePath, toFullPath, toShortPath } from "./utils"; -import { IFileWatcher, FileWatcher, watch } from "./watch"; +import { find, getCommitShortHash, isDescendant, pathEquals, relativePath, toFullPath, toShortPath } from "./utils"; +import { FileWatcher, IFileWatcher, watch } from "./watch"; const helpers = acode.require('helpers'); const Url = acode.require('url'); @@ -570,6 +570,11 @@ export class Repository implements IDisposable { return this._submodules; } + private _worktrees: Worktree[] = []; + get worktrees(): Worktree[] { + return this._worktrees; + } + private _rebaseCommit: Commit | undefined = undefined; set rebaseCommit(rebaseCommit: Commit | undefined) { if (this._rebaseCommit && !rebaseCommit) { @@ -910,6 +915,39 @@ export class Repository implements IDisposable { await this.run(Operation.DeleteTag, () => this.repository.deleteTag(name)); } + async createWorktree(options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise { + const gitConfig = config.get('vcgit')!; + const branchPrefix = gitConfig.branchPrefix; + + return await this.run(Operation.Worktree(false), async () => { + let worktreeName: string | undefined; + let { path: worktreePath, commitish, branch, noTrack } = options || {}; + + // Create worktree path based on the branch name + if (worktreePath === undefined && branch !== undefined) { + worktreeName = branch.startsWith(branchPrefix) + ? branch.substring(branchPrefix.length).replace(/\//g, '-') + : branch.replace(/\//g, '-'); + worktreeName = Url.join(Url.dirname(this.root), `${Url.basename(this.root)}.worktrees`, worktreeName); + } + + // Ensure that the worktree path is unique + if (this.worktrees.some(worktree => pathEquals(worktree.path, worktreePath!))) { + let counter = 0, uniqueWorktreePath: string; + do { + uniqueWorktreePath = `${worktreePath}-${++counter}`; + } while (this.worktrees.some(wt => pathEquals(wt.path, uniqueWorktreePath))); + + worktreePath = uniqueWorktreePath; + } + + // Create the worktree + await this.repository.addWorktree({ path: worktreePath!, commitish: commitish ?? 'HEAD', branch, noTrack }); + + return worktreePath!; + }); + } + async checkout(treeish: string, opts?: { detached?: boolean; pullBeforeCheckout?: boolean }): Promise { const refLabel = opts?.detached ? getCommitShortHash(treeish) : treeish; @@ -1559,10 +1597,11 @@ export class Repository implements IDisposable { this._updateResourceGroupsState(optimisticResourcesGroups); } - const [HEAD, remotes, submodules, rebaseCommit, mergeInProgress] = await Promise.all([ + const [HEAD, remotes, submodules, worktrees, rebaseCommit, mergeInProgress] = await Promise.all([ this.repository.getHEADRef(), this.repository.getRemotes(), this.repository.getSubmodules(), + this.repository.getWorktrees(), this.getRebaseCommit(), this.isMergeInProgress() ]); @@ -1570,6 +1609,7 @@ export class Repository implements IDisposable { this._HEAD = HEAD; this._remotes = remotes; this._submodules = submodules; + this._worktrees = worktrees!; this.rebaseCommit = rebaseCommit; this.mergeInProgress = mergeInProgress; diff --git a/src/main.ts b/src/main.ts index b57d758..6ce14bf 100644 --- a/src/main.ts +++ b/src/main.ts @@ -491,6 +491,12 @@ function initializeMenus(logger: LogOutputChannel): void { submenu: true, when: (ctx: SCMMenuContext) => ctx.scmProvider === 'git' }, + { + command: { id: 'git.worktrees', title: 'Worktrees' }, + group: '2_main@7', + submenu: true, + when: (ctx: SCMMenuContext) => ctx.scmProvider === 'git' + }, { command: { id: 'git.showOutput', title: 'Show Git Output' }, group: '3_footer@1', @@ -921,6 +927,15 @@ function initializeMenus(logger: LogOutputChannel): void { enablement: () => !App.getContext('git.operationInProgress') } ]); + + // Worktrees + SCMMenuRegistry.registerMenuItems('git.worktrees', [ + { + command: { id: 'git.createWorktree', title: 'Create Worktree...' }, + group: 'worktrees@1', + enablement: () => !App.getContext('git.operationInProgress') + } + ]); } function gitPluginSettings(): Acode.PluginSettings { From 665450d700dfc8ea6ee14bcad97f13b6d9135e4a Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Fri, 5 Jun 2026 09:52:46 +0700 Subject: [PATCH 05/20] feat: add worktree detection --- src/git/model.ts | 23 +++++++++++++++++++++++ src/main.ts | 18 +++++++++++++++++- typings/typing.d.ts | 2 ++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/git/model.ts b/src/git/model.ts index 2ecb220..ff7d027 100644 --- a/src/git/model.ts +++ b/src/git/model.ts @@ -505,6 +505,8 @@ export class Model implements IRepositoryResolver, IRemoteSourcePublisherRegistr const gitConfig = config.get('vcgit')!; const shouldDetectSubmodules = gitConfig.detectSubmodules; const submodulesLimit = gitConfig.detectSubmodulesLimit; + const shouldDetectWorktrees = gitConfig.detectWorktrees; + const worktreesLimit = gitConfig.detectWorktreesLimit; const checkForSubmodules = () => { if (!shouldDetectSubmodules) { @@ -526,9 +528,30 @@ export class Model implements IRepositoryResolver, IRemoteSourcePublisherRegistr }); } + const checkForWorktrees = () => { + if (!shouldDetectWorktrees) { + this.logger.info('[Model][open] Automatic detection of git worktrees is not enabled.'); + return; + } + + if (repository.worktrees.length > worktreesLimit) { + acode.alert('WARNING', `The "${Url.basename(repository.root)}" repository has ${repository.worktrees.length} worktrees which won't be opened automatically. You can still open each one individually by opening a file within.`); + statusListener.dispose(); + } + + repository.worktrees + .slice(0, worktreesLimit) + .forEach(w => { + this.logger.info(`[Model][open] Opening worktree: '${w.path}'`); + this.eventuallyScanPossibleGitRepository(w.path); + }); + } + const statusListener = repository.onDidRunGitStatus(() => { checkForSubmodules(); + checkForWorktrees(); }); + checkForWorktrees(); const updateOperationInProgressContext = () => { let operationInProgress = false; diff --git a/src/main.ts b/src/main.ts index 6ce14bf..2351a62 100644 --- a/src/main.ts +++ b/src/main.ts @@ -89,7 +89,9 @@ const defaultGitConfig: IGitConfig = { openDiffOnClick: true, showDecorationInFileTree: true, refreshOnSaveFile: false, - optimisticUpdate: true + optimisticUpdate: true, + detectWorktrees: false, + detectWorktreesLimit: 20 } async function destroy() { @@ -1353,6 +1355,20 @@ function gitPluginSettings(): Acode.PluginSettings { checkbox: configs.optimisticUpdate, text: 'Git: Optimistic Update (Experimental)', info: 'Controls whether to optimistically update the state of the Source Control view after running git commands.' + }, + { + key: 'detectWorktrees', + checkbox: configs.detectWorktrees, + text: 'Git: Detect Worktrees', + info: 'Controls whether to automatically detect Git worktrees.' + }, + { + key: 'detectWorktreesLimit', + value: configs.detectWorktreesLimit, + text: 'Git: Detect Worktrees Limit', + info: 'Controls the limit of Git worktrees detected.', + prompt: 'Detect Worktrees Limit', + promptType: 'number' } ], cb(key: string, value: unknown) { diff --git a/typings/typing.d.ts b/typings/typing.d.ts index 870befc..69d47ef 100644 --- a/typings/typing.d.ts +++ b/typings/typing.d.ts @@ -70,6 +70,8 @@ interface IGitConfig { readonly showDecorationInFileTree: boolean; readonly refreshOnSaveFile: boolean; readonly optimisticUpdate: boolean; + readonly detectWorktrees: boolean; + readonly detectWorktreesLimit: number; } declare namespace Acode { From 3a778c7352f6bf6eee27369b65676dcecfdb0621 Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Fri, 5 Jun 2026 10:03:35 +0700 Subject: [PATCH 06/20] fix: update rawPath construction to use toFullPath for --- src/git/git.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/git/git.ts b/src/git/git.ts index 54ba6a6..a9bec5f 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -323,7 +323,7 @@ export class Git { } } - const rawPath = Url.join(`file://${commonDotGitPath ?? dotGitPath}`, 'config'); + const rawPath = Url.join(`file://${toFullPath(commonDotGitPath ?? dotGitPath)}`, 'config'); const raw = await fs(rawPath).readFile('utf-8'); const coreSections = GitConfigParser.parse(raw).find(s => s.name === 'core'); const isBare = coreSections?.properties['bare'] === 'true'; From 9d29eb6de639d1dd7bc88f79c1ab99305b08f626 Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Fri, 5 Jun 2026 18:53:21 +0700 Subject: [PATCH 07/20] fix: repository not automatically initializing when git enabled is true --- src/git/model.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/git/model.ts b/src/git/model.ts index 2ecb220..d945d88 100644 --- a/src/git/model.ts +++ b/src/git/model.ts @@ -302,6 +302,7 @@ export class Model implements IRepositoryResolver, IRemoteSourcePublisherRegistr this._unsafeRepositoriesManager = new UnsafeRepositoriesManager(); App.onDidChangeWorkspaceFolder(this.onDidChangeWorkspaceFolder, this, this.disposables); + config.onDidChangeConfiguration(this.onDidChangeConfiguration, this, this.disposables); this.setState('uninitialized'); this.doInitialScan().finally(() => this.setState('initialized')); @@ -412,6 +413,23 @@ export class Model implements IRepositoryResolver, IRemoteSourcePublisherRegistr } } + private onDidChangeConfiguration(): void { + const gitConfig = config.get('vcgit')!; + const enabled = gitConfig.enabled; + + const possibleRepositoryFolders = enabled === true + ? addedFolder.filter(folder => !this.getOpenRepository(folder.url)) + : []; + + const openRepositoriesToDispose = enabled !== true + ? this.openRepositories + : []; + + this.logger.info(`[Model][onDidChangeConfiguration] Workspace folders: [${possibleRepositoryFolders.map(p => uriToPath(p.url)).join(', ')}]`); + possibleRepositoryFolders.forEach(p => this.openRepository(uriToPath(p.url))); + openRepositoriesToDispose.forEach(r => r.dispose()); + } + @sequentialize async openRepository(repoPath: string, openIfClosed = false): Promise { this.logger.info(`[Model][openRepository] Repository: ${repoPath}`); From 26544ab18fee6c461ffba3a3cdec2bdab04e86e3 Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Fri, 5 Jun 2026 19:58:48 +0700 Subject: [PATCH 08/20] fix: normalize worktrees gitdir --- src/git/git.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/git/git.ts b/src/git/git.ts index a9bec5f..40375f9 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -855,6 +855,14 @@ function parseRefs(data: string): (Ref | Branch)[] { return refs; } +function normalizeGitdir(gitdir: string): string { + if (gitdir.startsWith(`/data/data/${window.BuildInfo.packageName}/files/public`)) { + return gitdir.replace('/data/data/', '/data/user/0/'); + } + + return gitdir; +} + export interface PullOptions { readonly unshallow?: boolean; readonly tags?: boolean; @@ -2017,7 +2025,7 @@ export class Repository { result.push({ name: dirent.name, // Remove '/.git' suffix - path: toFullPath(gitdirContent.replace(/\/.git.*$/, '')), + path: normalizeGitdir(toFullPath(gitdirContent.replace(/\/.git.*$/, ''))), // Remove 'ref: ' prefix ref: headContent.replace(/^ref: /, ''), // Detached if HEAD does not start with 'ref: ' From 8c1afa21b26dc598718362058a0c18fa0559de73 Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Sun, 7 Jun 2026 17:48:23 +0700 Subject: [PATCH 09/20] feat: Add support for submodule and worktree repository types with custom icons - Introduce RepositoryKind type to distinguish repository types (repository, submodule, worktree) - Add logic to detect repository kind based on dotGit properties - Add custom SVG icons for submodules (archive icon) and worktrees (worktree icon) - Update SCM UI to display appropriate icon based on repository type - Register new icons in plugin initialization --- dist/assets/vscode-codicons_archive.svg | 1 + dist/assets/vscode-codicons_worktree.svg | 1 + plugin.json | 4 +++- src/git/api/git.d.ts | 2 ++ src/git/git.ts | 9 +++++++++ src/git/repository.ts | 14 ++++++++++++-- src/main.ts | 2 ++ 7 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 dist/assets/vscode-codicons_archive.svg create mode 100644 dist/assets/vscode-codicons_worktree.svg diff --git a/dist/assets/vscode-codicons_archive.svg b/dist/assets/vscode-codicons_archive.svg new file mode 100644 index 0000000..c5d8db2 --- /dev/null +++ b/dist/assets/vscode-codicons_archive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dist/assets/vscode-codicons_worktree.svg b/dist/assets/vscode-codicons_worktree.svg new file mode 100644 index 0000000..e265df8 --- /dev/null +++ b/dist/assets/vscode-codicons_worktree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugin.json b/plugin.json index d41d644..95407d6 100644 --- a/plugin.json +++ b/plugin.json @@ -18,7 +18,9 @@ "assets/debug-disconnect.svg", "assets/refresh.svg", "assets/loading.svg", - "assets/git-commit.svg" + "assets/git-commit.svg", + "assets/vscode-codicons_archive.svg", + "assets/vscode-codicons_worktree.svg" ], "minVersionCode": 290, "license": "MIT", diff --git a/src/git/api/git.d.ts b/src/git/api/git.d.ts index ee99d9e..a9f0787 100644 --- a/src/git/api/git.d.ts +++ b/src/git/api/git.d.ts @@ -116,6 +116,8 @@ export interface Change { readonly status: Status; } +export type RepositoryKind = 'repository' | 'submodule' | 'worktree'; + export interface RepositoryState { readonly HEAD: Branch | undefined; readonly remotes: Remote[]; diff --git a/src/git/git.ts b/src/git/git.ts index 40375f9..c452d54 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -883,7 +883,16 @@ export class Repository { readonly dotGit: IDotGit, private logger: LogOutputChannel ) { + this._kind = this.dotGit.commonPath + ? 'worktree' + : this.dotGit.superProjectPath + ? 'submodule' + : 'repository'; + } + private readonly _kind: 'repository' | 'submodule' | 'worktree'; + get kind(): 'repository' | 'submodule' | 'worktree' { + return this._kind; } get git(): Git { diff --git a/src/git/repository.ts b/src/git/repository.ts index 9a4e45a..9a75881 100644 --- a/src/git/repository.ts +++ b/src/git/repository.ts @@ -10,7 +10,7 @@ import { SourceControl, SourceControlCommandAction, SourceControlInputBox, Sourc import { ActionButton } from "./actionButton"; import { CommandActions } from "./actions"; import { ApiRepository } from "./api/api1"; -import { Branch, BranchQuery, Change, Commit, CommitOptions, FetchOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, Status } from "./api/git"; +import { Branch, BranchQuery, Change, Commit, CommitOptions, FetchOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, RepositoryKind, Status } from "./api/git"; import { AutoFetcher } from "./autofetch"; import { Repository as BaseRepository, GitError, LsTreeElement, PullOptions, RefQuery, Stash, Submodule, Worktree } from "./git"; import { LogOutputChannel } from "./logger"; @@ -637,6 +637,10 @@ export class Repository implements IDisposable { return this.repository.dotGit; } + get kind(): RepositoryKind { + return this.repository.kind; + } + private isRepositoryHuge: false | { limit: number } = false; private didWarnAboutLimit = false; @@ -680,7 +684,13 @@ export class Repository implements IDisposable { this.disposables.push(new FileEventLogger(onRepositoryWorkingTreeFileChange, onRepositoryDotGitFileChange, logger)); - this._sourceControl = scm.createSourceControl('git', 'Git', this.repository.root, undefined); + const icon = repository.kind === 'submodule' + ? 'vscode-codicons_archive' + : repository.kind === 'worktree' + ? 'vscode-codicons_worktree' + : 'repo' + + this._sourceControl = scm.createSourceControl('git', 'Git', this.repository.root, icon); this.disposables.push(this._sourceControl); this.updateInputBoxPlaceholder(); diff --git a/src/main.ts b/src/main.ts index 2351a62..1d1df99 100644 --- a/src/main.ts +++ b/src/main.ts @@ -242,6 +242,8 @@ async function initialize(baseUrl: string, options: Acode.PluginInitOptions): Pr acode.addIcon('tag', baseUrl + 'assets/tag.svg', { monochrome: true }); acode.addIcon('loading', baseUrl + 'assets/loading.svg', { monochrome: true }); acode.addIcon('git-commit', baseUrl + 'assets/git-commit.svg', { monochrome: true }); + acode.addIcon('vscode-codicons_archive', baseUrl + 'assets/vscode-codicons_archive.svg', { monochrome: true }); + acode.addIcon('vscode-codicons_worktree', baseUrl + 'assets/vscode-codicons_worktree.svg', { monochrome: true }); const styles = tag('link', { rel: 'stylesheet', href: baseUrl + 'main.css' }); document.head.appendChild(styles); disposables.push(Disposable.toDisposable(() => styles.remove())); From cff794bc05f669bc40563712c42eab4f825b7ea0 Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Sun, 7 Jun 2026 20:34:51 +0700 Subject: [PATCH 10/20] feat: Add contextValue support to SCM menus and resource grouping - Add contextValue property to ISCMProvider, ISCMResource, and ISCMResourceGroup interfaces for context-aware menu rendering - Implement contextual menu support in SCMMenusItem using Map-based storage for context-specific menu instances - Enable dynamic menu creation based on resourceGroup.contextValue and resource.contextValue properties - Add new context properties to SCMMenuContext: scmResourceGroupState, scmResourceState, and scmProviderContext for menu filtering - Refactor getResourceGroupMenu() and getResourceMenu() to support both generic and context-specific menu instances - Add contextValue to SCMRawResource and SCMProviderFeatures types - Update SCMProvider to track and propagate contextValue to resource groups - Add IContextualMenuItem interface for managing context-specific menu lifecycles - Clean up code formatting and indentation in menus.ts - Update API type definitions in sourceControl.d.ts with contextValue support --- src/git/repository.ts | 1 + src/main.ts | 15 ++- src/scm/api/sourceControl.d.ts | 3 + src/scm/menus.ts | 178 ++++++++++++++++++++++++++----- src/scm/scm.ts | 30 +++++- src/scm/scmProvider.ts | 38 +++++-- src/scm/scmRepositoriesView.ts | 2 +- src/scm/scmRepositoryRenderer.ts | 9 +- src/scm/scmView.ts | 19 ++-- src/scm/types.ts | 13 ++- 10 files changed, 253 insertions(+), 55 deletions(-) diff --git a/src/git/repository.ts b/src/git/repository.ts index 9a75881..e5bda7d 100644 --- a/src/git/repository.ts +++ b/src/git/repository.ts @@ -691,6 +691,7 @@ export class Repository implements IDisposable { : 'repo' this._sourceControl = scm.createSourceControl('git', 'Git', this.repository.root, icon); + this._sourceControl.contextValue = repository.kind; this.disposables.push(this._sourceControl); this.updateInputBoxPlaceholder(); diff --git a/src/main.ts b/src/main.ts index 1d1df99..4feaefe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -934,10 +934,23 @@ function initializeMenus(logger: LogOutputChannel): void { // Worktrees SCMMenuRegistry.registerMenuItems('git.worktrees', [ + { + command: { id: 'git.openWorktree', title: 'Open Worktree' }, + group: 'openWorktrees@1', + enablement: () => !App.getContext('git.operationInProgress'), + when: (ctx: SCMMenuContext) => ctx.scmProviderContext === 'worktree' + }, { command: { id: 'git.createWorktree', title: 'Create Worktree...' }, group: 'worktrees@1', - enablement: () => !App.getContext('git.operationInProgress') + enablement: () => !App.getContext('git.operationInProgress'), + when: (ctx: SCMMenuContext) => ctx.scmProviderContext === 'repository' + }, + { + command: { id: 'git.deleteWorktree2', title: 'Delete Worktree' }, + group: 'worktrees@2', + enablement: () => !App.getContext('git.operationInProgress'), + when: (ctx: SCMMenuContext) => ctx.scmProviderContext === 'worktree' } ]); } diff --git a/src/scm/api/sourceControl.d.ts b/src/scm/api/sourceControl.d.ts index d286e9b..5b37013 100644 --- a/src/scm/api/sourceControl.d.ts +++ b/src/scm/api/sourceControl.d.ts @@ -6,6 +6,7 @@ export interface SourceControl { readonly rootUri?: string; readonly inputBox: SourceControlInputBox; count?: number; + contextValue?: string; commandActions: SourceControlCommandAction[] | undefined; actionButton: SourceControlActionButton | undefined; readonly selected: boolean; @@ -26,6 +27,7 @@ export interface SourceControlResourceGroup { readonly id: string; label: string; hideWhenEmpty?: boolean; + contextValue?: string; resourceStates: SourceControlResourceState[]; dispose(): void; } @@ -34,6 +36,7 @@ export interface SourceControlResourceState { readonly resourceUri: string; decorations?: SourceControlResourceDecorations; command?: SourceControlCommandAction; + readonly contextValue?: string; } export interface SourceControlResourceDecorations { diff --git a/src/scm/menus.ts b/src/scm/menus.ts index 1c4644a..e2bf798 100644 --- a/src/scm/menus.ts +++ b/src/scm/menus.ts @@ -86,7 +86,7 @@ class SCMMenu implements ISCMMenu { private readonly disposables = new DisposableStore(); - constructor(private readonly menuId: string, private readonly context: SCMMenuContext) { + constructor(private readonly menuId: string, readonly context: SCMMenuContext) { this.build(); // Listen to menu registry changes @@ -168,46 +168,117 @@ class SCMMenu implements ISCMMenu { } } +interface IContextualMenuItem { + readonly menu: ISCMMenu; + dispose(): void; +} + class SCMMenusItem implements IDisposable { private _resourceFolderMenu: ISCMMenu | undefined; get resourceFolderMenu(): ISCMMenu { - if (!this._resourceFolderMenu) { - this._resourceFolderMenu = new SCMMenu('scm/resourceFolder/context', this.context); - } + if (!this._resourceFolderMenu) { + this._resourceFolderMenu = new SCMMenu('scm/resourceFolder/context', this.context); + } - return this._resourceFolderMenu; - } + return this._resourceFolderMenu; + } private genericResourceGroupMenu: ISCMMenu | undefined; + private contextualResourceGroupMenus: Map | undefined; + private genericResourceMenu: ISCMMenu | undefined; + private contextualResourceMenus: Map | undefined; constructor(private context: SCMMenuContext) { } getResourceGroupMenu(resourceGroup: ISCMResourceGroup): ISCMMenu { - if (!this.genericResourceGroupMenu) { - this.genericResourceGroupMenu = new SCMMenu('scm/resourceGroup/context', this.context); + if (typeof resourceGroup.contextValue === 'undefined') { + if (!this.genericResourceGroupMenu) { + this.genericResourceGroupMenu = new SCMMenu('scm/resourceGroup/context', this.context); + } + + return this.genericResourceGroupMenu; + } + + if (!this.contextualResourceGroupMenus) { + this.contextualResourceGroupMenus = new Map(); + } + + let item = this.contextualResourceGroupMenus.get(resourceGroup.contextValue); + + if (!item) { + const context = { ...this.context, scmResourceGroupState: resourceGroup.contextValue }; + const menu = new SCMMenu('scm/resourceGroup/context', context); + + item = { + menu, dispose() { + menu.dispose(); + } + }; + + this.contextualResourceGroupMenus.set(resourceGroup.contextValue, item); } - return this.genericResourceGroupMenu; + + return item.menu; } getResourceMenu(resource: ISCMResource): ISCMMenu { - if (!this.genericResourceMenu) { - this.genericResourceMenu = new SCMMenu('scm/resourceState/context', this.context); + if (typeof resource.contextValue === 'undefined') { + if (!this.genericResourceMenu) { + this.genericResourceMenu = new SCMMenu('scm/resourceState/context', this.context); + } + + return this.genericResourceMenu; + } + + if (!this.contextualResourceMenus) { + this.contextualResourceMenus = new Map(); + } + + let item = this.contextualResourceMenus.get(resource.contextValue); + + if (!item) { + const context = { ...this.context, scmResourceState: resource.contextValue }; + const menu = new SCMMenu('scm/resourceState/context', context); + + item = { + menu, dispose() { + menu.dispose(); + } + }; + + this.contextualResourceMenus.set(resource.contextValue, item); } - return this.genericResourceMenu; + + return item.menu; } dispose(): void { this.genericResourceGroupMenu?.dispose(); this.genericResourceMenu?.dispose(); + + if (this.contextualResourceGroupMenus) { + this.contextualResourceGroupMenus.forEach(c => c.dispose()); + this.contextualResourceGroupMenus.clear(); + this.contextualResourceGroupMenus = undefined; + } + + if (this.contextualResourceMenus) { + this.contextualResourceMenus.forEach(c => c.dispose()); + this.contextualResourceMenus.clear(); + this.contextualResourceMenus = undefined; + } } } class SCMRepositoryMenus implements IDisposable, ISCMRepositoryMenus { - private repositoryMenu: ISCMMenu | undefined; - private repositoryContextMenu: ISCMMenu | undefined; + private genericRepositoryMenu: ISCMMenu | undefined; + private contextualRepositoryMenus: Map | undefined; + + private genericRepositoryContextMenu: ISCMMenu | undefined; + private contextualRepositoryContextMenus: Map | undefined; private context: SCMMenuContext; @@ -227,17 +298,67 @@ class SCMRepositoryMenus implements IDisposable, ISCMRepositoryMenus { } getRepositoryMenu(repository: ISCMRepository): ISCMMenu { - if (!this.repositoryMenu) { - this.repositoryMenu = new SCMMenu('scm/repository/menu', this.context); + const contextValue = repository.provider.contextValue; + if (typeof contextValue === 'undefined') { + if (!this.genericRepositoryMenu) { + this.genericRepositoryMenu = new SCMMenu('scm/repository/menu', this.context); + } + + return this.genericRepositoryMenu; + } + + if (!this.contextualRepositoryMenus) { + this.contextualRepositoryMenus = new Map(); + } + + let item = this.contextualRepositoryMenus.get(contextValue); + + if (!item) { + const context = { ...this.context, scmProviderContext: contextValue }; + const menu = new SCMMenu('scm/repository/menu', context); + + item = { + menu, dispose() { + menu.dispose(); + } + }; + + this.contextualRepositoryMenus.set(contextValue, item); } - return this.repositoryMenu; + + return item.menu; } getRepositoryContextMenu(repository: ISCMRepository): ISCMMenu { - if (!this.repositoryContextMenu) { - this.repositoryContextMenu = new SCMMenu('scm/sourceControl', this.context); + const contextValue = repository.provider.contextValue; + if (typeof contextValue === 'undefined') { + if (!this.genericRepositoryContextMenu) { + this.genericRepositoryContextMenu = new SCMMenu('scm/sourceControl', this.context); + } + + return this.genericRepositoryContextMenu; + } + + if (!this.contextualRepositoryContextMenus) { + this.contextualRepositoryContextMenus = new Map(); + } + + let item = this.contextualRepositoryContextMenus.get(contextValue); + + if (!item) { + const context = { ...this.context, scmProviderContext: contextValue }; + const menu = new SCMMenu('scm/sourceControl', context); + + item = { + menu, dispose() { + menu.dispose(); + } + }; + + this.contextualRepositoryContextMenus.set(contextValue, item); } - return this.repositoryContextMenu; + + return item.menu; } getResourceGroupMenu(group: ISCMResourceGroup): ISCMMenu { @@ -249,11 +370,11 @@ class SCMRepositoryMenus implements IDisposable, ISCMRepositoryMenus { } getResourceFolderMenu(group: ISCMResourceGroup): ISCMMenu { - return this.getOrCreateResourceGroupMenusItem(group).resourceFolderMenu; - } + return this.getOrCreateResourceGroupMenusItem(group).resourceFolderMenu; + } - getSubmenu(submenu: string): ISCMMenu { - return new SCMMenu(submenu, this.context); + getSubmenu(menu: ISCMMenu, submenu: string): ISCMMenu { + return new SCMMenu(submenu, menu.context); } private getOrCreateResourceGroupMenusItem(group: ISCMResourceGroup): SCMMenusItem { @@ -276,8 +397,13 @@ class SCMRepositoryMenus implements IDisposable, ISCMRepositoryMenus { } dispose(): void { - this.repositoryMenu?.dispose(); - this.repositoryContextMenu?.dispose(); + this.genericRepositoryMenu?.dispose(); + this.genericRepositoryContextMenu?.dispose(); + if (this.contextualRepositoryMenus) { + this.contextualRepositoryMenus.forEach(c => c.dispose()); + this.contextualRepositoryMenus.clear(); + this.contextualRepositoryMenus = undefined; + } this.resourceGroupMenusItems.forEach(item => item.dispose()); this.disposables.dispose(); } diff --git a/src/scm/scm.ts b/src/scm/scm.ts index dea341b..d6c64d3 100644 --- a/src/scm/scm.ts +++ b/src/scm/scm.ts @@ -218,6 +218,15 @@ class SourceControlResourceGroupImpl implements SourceControlResourceGroup { this.#scm.updateGroupLabel(this._sourceControlHandle, this.handle, label); } + private _contextValue: string | undefined = undefined; + get contextValue(): string | undefined { + return this._contextValue; + } + set contextValue(contextValue: string | undefined) { + this._contextValue = contextValue; + this.#scm.updateGroup(this._sourceControlHandle, this.handle, { contextValue: this._contextValue, hideWhenEmpty: this._hideWhenEmpty }); + } + private _hideWhenEmpty: boolean | undefined = undefined; get hideWhenEmpty(): boolean | undefined { return this._hideWhenEmpty }; set hideWhenEmpty(hideWhenEmpty: boolean | undefined) { @@ -273,8 +282,9 @@ class SourceControlResourceGroupImpl implements SourceControlResourceGroup { const icon = r.decorations?.icon; const strikeThrough = r.decorations && !!r.decorations.strikeThrough; + const contextValue = r.contextValue || ''; - const rawResource = [handle, sourceUri, icon, strikeThrough] as SCMRawResource; + const rawResource = [handle, sourceUri, icon, strikeThrough, contextValue] as SCMRawResource; return { rawResource, handle }; }); @@ -408,6 +418,20 @@ class SourceControlImpl implements SourceControl { return this._rootUri; } + private _contextValue: string | undefined = undefined; + get contextValue(): string | undefined { + return this._contextValue; + } + + set contextValue(contextValue: string | undefined) { + if (this._contextValue === contextValue) { + return; + } + + this._contextValue = contextValue; + this.#scm.updateSourceControl(this.handle, { contextValue }); + } + private _inputBox: SourceControlInputBox; get inputBox(): SourceControlInputBox { return this._inputBox; } @@ -477,7 +501,7 @@ class SourceControlImpl implements SourceControl { @debounce(300) eventuallyAddResourceGroups(): void { - const groups: [number /* handle */, string /* id */, string /* label */, boolean | undefined /* hideWhenEmpty */][] = []; + const groups: [number /* handle */, string /* id */, string /* label */, boolean | undefined /* hideWhenEmpty */, string | undefined /* contextValue */][] = []; const splices: SCMRawResourceSplices[] = []; for (const [group, disposable] of this.createdResourceGroups) { @@ -495,7 +519,7 @@ class SourceControlImpl implements SourceControl { this.#scm.unregisterGroup(this.handle, group.handle); }); - groups.push([group.handle, group.id, group.label, group.hideWhenEmpty]); + groups.push([group.handle, group.id, group.label, group.hideWhenEmpty, group.contextValue]); const snapshot = group._takeResourceStateSnapshot(); diff --git a/src/scm/scmProvider.ts b/src/scm/scmProvider.ts index 08837c5..bbecbd8 100644 --- a/src/scm/scmProvider.ts +++ b/src/scm/scmProvider.ts @@ -40,13 +40,20 @@ class SCMResourceGroup implements ISCMResourceGroup { this._onDidChange.fire(); } + get contextValue(): string | undefined { return this._contextValue; } + set contextValue(contextValue: string | undefined) { + this._contextValue = contextValue; + this._onDidChange.fire(); + } + constructor( private readonly sourceControlHandle: number, private readonly handle: number, public provider: ISCMProvider, public id: string, private _label: string, - private _hideWhenEmpty?: boolean + private _hideWhenEmpty?: boolean, + private _contextValue?: string ) { } @@ -74,7 +81,8 @@ class SCMResource implements ISCMResource { private readonly handle: number, readonly sourceUri: string, readonly resourceGroup: ISCMResourceGroup, - readonly decorations: ISCMResourceDecoration + readonly decorations: ISCMResourceDecoration, + readonly contextValue: string | undefined, ) { } @@ -115,6 +123,11 @@ class SCMProvider implements ISCMProvider { get rootUri(): string | undefined { return this._rootUri; } get icon(): string | undefined { return this._icon; } + private _contextValue: string | undefined = undefined; + get contextValue(): string | undefined { + return this._contextValue; + } + private readonly _name: string | undefined; get name(): string { return this._name ?? this.label } @@ -165,14 +178,19 @@ class SCMProvider implements ISCMProvider { this._actionButton = features.actionButton; } + if (typeof features.contextValue !== 'undefined') { + changed = true; + this._contextValue = features.contextValue; + } + if (changed) { this._onDidChange.fire(); } } - registerGroups(_groups: [number /* handle */, string /* id */, string /* label */, boolean | undefined /* hideWhenEmpty */][]) { + registerGroups(_groups: [number /* handle */, string /* id */, string /* label */, boolean | undefined /* hideWhenEmpty */, string | undefined /* contextValue */][]) { const groups = _groups.map(([handle, id, label, hideWhenEmpty]) => { - const group = new SCMResourceGroup(this.handle, handle, this, id, label, hideWhenEmpty); + const group = new SCMResourceGroup(this.handle, handle, this, id, label, hideWhenEmpty, this.contextValue); this._groupsByHandle[handle] = group; return group; }); @@ -181,7 +199,7 @@ class SCMProvider implements ISCMProvider { this._onDidChangeResourceGroups.fire(); } - updateGroup(handle: number, features: { hideWhenEmpty?: boolean }): void { + updateGroup(handle: number, features: { hideWhenEmpty?: boolean, contextValue?: string }): void { const group = this._groupsByHandle[handle]; if (!group) { @@ -189,6 +207,7 @@ class SCMProvider implements ISCMProvider { } group.hideWhenEmpty = !!features.hideWhenEmpty; + group.contextValue = this.contextValue; } updateGroupLabel(handle: number, label: string): void { @@ -214,7 +233,7 @@ class SCMProvider implements ISCMProvider { for (const [start, deleteCount, rawResources] of groupSlices) { const resources = rawResources.map(rawResource => { - const [handle, sourceUri, icon, strikeThrough] = rawResource; + const [handle, sourceUri, icon, strikeThrough, contextValue] = rawResource; const decorations = { icon, @@ -228,7 +247,8 @@ class SCMProvider implements ISCMProvider { handle, sourceUri, group, - decorations + decorations, + contextValue ); }); @@ -294,7 +314,7 @@ export class SCM { } } - registerGroups(sourceControlHandle: number, groups: [number /* handle */, string /* id */, string /* label */, boolean | undefined /* hideWhenEmpty */][], splices: SCMRawResourceSplices[]): void { + registerGroups(sourceControlHandle: number, groups: [number /* handle */, string /* id */, string /* label */, boolean | undefined /* hideWhenEmpty */, string | undefined /* contextValue */][], splices: SCMRawResourceSplices[]): void { const repository = this._repositories.get(sourceControlHandle); if (!repository) { @@ -306,7 +326,7 @@ export class SCM { provider.spliceGroupResourceStates(splices); } - updateGroup(sourceControlHandle: number, groupHandle: number, features: { hideWhenEmpty?: boolean }): void { + updateGroup(sourceControlHandle: number, groupHandle: number, features: { hideWhenEmpty?: boolean, contextValue?: string }): void { const repository = this._repositories.get(sourceControlHandle); if (!repository) { diff --git a/src/scm/scmRepositoriesView.ts b/src/scm/scmRepositoriesView.ts index f6b3029..58c4ec4 100644 --- a/src/scm/scmRepositoriesView.ts +++ b/src/scm/scmRepositoriesView.ts @@ -108,7 +108,7 @@ export class ScmRepositoriesView extends Disposable.Disposable implements IView if (!submenu) { return menu.getSecondaryActions(); } else { - return menus.getSubmenu(submenu).getSecondaryActions(); + return menus.getSubmenu(menu, submenu).getSecondaryActions(); } }, onSelect: (id: string) => { diff --git a/src/scm/scmRepositoryRenderer.ts b/src/scm/scmRepositoryRenderer.ts index accaf7d..f75342a 100644 --- a/src/scm/scmRepositoryRenderer.ts +++ b/src/scm/scmRepositoryRenderer.ts @@ -81,7 +81,7 @@ class RepositoryAction implements IDisposable { this.actionContainer.appendChild(actionItem); actionItem.classList.toggle('disabled', !action.enabled); - + Event.fromDOMEvent(actionItem, 'click')(e => { e.stopPropagation(); this.scmCommandService.executeCommand(action.id, this.repository!.provider); @@ -116,12 +116,13 @@ class RepositoryAction implements IDisposable { private getActions(submenu?: string): ISCMMenuItemAction[] { const menus = this.scmViewService.menus.getRepositoryMenus(this.repository!.provider); + const repositoryMenu = menus.getRepositoryMenu(this.repository!); + if (submenu) { - const menu = menus.getSubmenu(submenu); + const menu = menus.getSubmenu(repositoryMenu, submenu); return menu.getSecondaryActions(); } else { - const menu = menus.getRepositoryMenu(this.repository!); - return menu.getSecondaryActions(); + return repositoryMenu.getSecondaryActions(); } } diff --git a/src/scm/scmView.ts b/src/scm/scmView.ts index 62ddbd9..c8e297f 100644 --- a/src/scm/scmView.ts +++ b/src/scm/scmView.ts @@ -6,7 +6,7 @@ import { CollapsableList, IListContextMenuEvent, IListDelegate, IListMouseEvent, import { SCMMenuItemAction } from "./menus"; import { IResourceNode, ResourceTree } from "./resourceTree"; import { RepositoryRenderer } from "./scmRepositoryRenderer"; -import { ISCMActionButton, ISCMActionButtonDescriptor, ISCMCommandService, ISCMInput, ISCMMenuItemAction, ISCMMenuService, ISCMRepository, ISCMRepositoryMenus, ISCMResource, ISCMResourceGroup, ISCMSeparator, ISCMService, ISCMViewService, ISCMViewVisibleRepositoryChangeEvent, ViewMode } from "./types"; +import { ISCMActionButton, ISCMActionButtonDescriptor, ISCMCommandService, ISCMInput, ISCMMenu, ISCMMenuItemAction, ISCMMenuService, ISCMRepository, ISCMRepositoryMenus, ISCMResource, ISCMResourceGroup, ISCMSeparator, ISCMService, ISCMViewService, ISCMViewVisibleRepositoryChangeEvent, ViewMode } from "./types"; import { disposableTimeout, isSCMActionButton, isSCMInput, isSCMRepository, isSCMResource, isSCMResourceGroup, isSCMResourceNode, isSCMSeparator, renderLabelWithIcon } from "./utils"; import { IView } from "./views"; @@ -845,6 +845,7 @@ export class SCMView extends Disposable.Disposable implements IView { const element = e.element; let menus: ISCMRepositoryMenus | undefined; + let menu: ISCMMenu | undefined; let actions: ISCMMenuItemAction[] = []; let context: unknown = element; let showSelectMenu: boolean = false; @@ -852,34 +853,34 @@ export class SCMView extends Disposable.Disposable implements IView { if (isSCMRepository(element)) { menus = this.scmViewService.menus.getRepositoryMenus(element.provider); - const menu = menus.getRepositoryContextMenu(element); + menu = menus.getRepositoryContextMenu(element); actions = menu.getSecondaryActions(); context = element.provider; } else if (isSCMInput(element) || isSCMActionButton(element)) { // noop } else if (isSCMResourceGroup(element)) { menus = this.scmViewService.menus.getRepositoryMenus(element.provider); - const menu = menus.getResourceGroupMenu(element); + menu = menus.getResourceGroupMenu(element); actions = menu.getSecondaryActions(); showSelectMenu = true; selectMenuTitle = `Group (${element.label})`; } else if (isSCMResource(element)) { menus = this.scmViewService.menus.getRepositoryMenus(element.resourceGroup.provider); - const menu = menus.getResourceMenu(element); + menu = menus.getResourceMenu(element); actions = menu.getSecondaryActions(); showSelectMenu = true; selectMenuTitle = Url.basename(element.sourceUri)!; } else if (isSCMResourceNode(element)) { if (element.element) { const menus = this.scmViewService.menus.getRepositoryMenus(element.element.resourceGroup.provider); - const menu = menus.getResourceMenu(element.element); + menu = menus.getResourceMenu(element.element); actions = menu.getSecondaryActions(); showSelectMenu = true; selectMenuTitle = Url.basename(element.element.sourceUri)!; context = element.element; } else { const menus = this.scmViewService.menus.getRepositoryMenus(element.context.provider); - const menu = menus.getResourceFolderMenu(element.context); + menu = menus.getResourceFolderMenu(element.context); actions = menu.getSecondaryActions(); showSelectMenu = true; selectMenuTitle = element.name; @@ -917,9 +918,9 @@ export class SCMView extends Disposable.Disposable implements IView { this.scmMenuService.showContextMenu({ toggler, getActions: (submenu: string) => { - if (submenu) { - const menu = menus?.getSubmenu(submenu); - return menu?.getSecondaryActions() ?? []; + if (menu && submenu) { + const menu2 = menus?.getSubmenu(menu, submenu); + return menu2?.getSecondaryActions() ?? []; } else { return actions; } diff --git a/src/scm/types.ts b/src/scm/types.ts index 9fa3d56..7d6514c 100644 --- a/src/scm/types.ts +++ b/src/scm/types.ts @@ -25,6 +25,7 @@ export interface ISCMResource { readonly resourceGroup: ISCMResourceGroup; readonly sourceUri: string; readonly decorations: ISCMResourceDecoration; + readonly contextValue: string | undefined; open(): boolean; } @@ -37,6 +38,7 @@ export interface ISCMResourceGroup { readonly onDidChangeResources: Event; readonly label: string; + contextValue: string | undefined; readonly hideWhenEmpty?: boolean; readonly onDidChange: Event; @@ -57,6 +59,7 @@ export interface ISCMProvider extends IDisposable { readonly rootUri?: string; readonly icon?: string; readonly count?: number; + readonly contextValue?: string; readonly commandActions: ISCMCommandAction[] | undefined; readonly actionButton: ISCMActionButtonDescriptor | undefined; } @@ -132,6 +135,7 @@ export interface ISCMMenuItemAction { } export interface ISCMMenu extends IDisposable { + readonly context: SCMMenuContext; readonly onDidChange: Event; getPrimaryActions(): ISCMMenuItemAction[]; getSecondaryActions(): ISCMMenuItemAction[]; @@ -145,7 +149,7 @@ export interface ISCMRepositoryMenus { getResourceGroupMenu(group: ISCMResourceGroup): ISCMMenu; getResourceMenu(resource: ISCMResource): ISCMMenu; getResourceFolderMenu(group: ISCMResourceGroup): ISCMMenu; - getSubmenu(submenu: string): ISCMMenu; + getSubmenu(menu: ISCMMenu, submenu: string): ISCMMenu; } export interface ISCMMenus { @@ -165,6 +169,9 @@ export interface SCMMenuContext { scmProviderRootUri?: string; scmProviderHasRoorUri?: boolean; scmResourceGroup?: string; + scmResourceGroupState?: string; + scmResourceState?: string; + scmProviderContext?: string; } export interface ISCMMenuService { @@ -266,7 +273,8 @@ export type SCMRawResource = [ number /* handle */, string /* resourceUri */, string | undefined /* icon */, - boolean /* strike through*/ + boolean /* strike through*/, + string | undefined /* context value */ ]; export type SCMRawResourceSplice = [ @@ -284,6 +292,7 @@ export interface SCMProviderFeatures { count?: number; commandActions?: ISCMCommandAction[]; actionButton?: ISCMActionButtonDescriptor; + contextValue?: string; } export interface SCMArgumentProcessor { From 1c072b566c5446474f509bfc7348643bb5efd156 Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Sun, 7 Jun 2026 20:49:59 +0700 Subject: [PATCH 11/20] fix: rename "openWorktreeInCurrentWindow" to "openWorktree" and add repository to true --- src/git/commands.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/git/commands.ts b/src/git/commands.ts index 306c588..94d64bd 100644 --- a/src/git/commands.ts +++ b/src/git/commands.ts @@ -2082,12 +2082,12 @@ export class CommandCenter { const choice = await showDialogMessage('WARNING', message, openWorktree); if (choice === openWorktree) { - this.openWorktreeInCurrentWindow(worktreeRepository); + this.openWorktree(worktreeRepository); } } - @command('Open Worktree') - openWorktreeInCurrentWindow(repository: Repository): void { + @command('Open Worktree', { repository: true }) + openWorktree(repository: Repository): void { if (!repository) { return; } From ad448da7587bb4eb986806899bc9dc30af73fbea Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Sun, 7 Jun 2026 21:31:00 +0700 Subject: [PATCH 12/20] feat: add worktree with delete functionality and new icons --- dist/assets/vscode-codicons_list_tree.svg | 1 + plugin.json | 3 +- src/git/commands.ts | 79 ++++++++++++++++++++++- src/git/git.ts | 11 ++++ src/git/repository.ts | 62 ++++++++++++++++++ src/git/utils.ts | 4 ++ src/main.ts | 2 + 7 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 dist/assets/vscode-codicons_list_tree.svg diff --git a/dist/assets/vscode-codicons_list_tree.svg b/dist/assets/vscode-codicons_list_tree.svg new file mode 100644 index 0000000..402ba55 --- /dev/null +++ b/dist/assets/vscode-codicons_list_tree.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugin.json b/plugin.json index 95407d6..fd20fb9 100644 --- a/plugin.json +++ b/plugin.json @@ -20,7 +20,8 @@ "assets/loading.svg", "assets/git-commit.svg", "assets/vscode-codicons_archive.svg", - "assets/vscode-codicons_worktree.svg" + "assets/vscode-codicons_worktree.svg", + "assets/vscode-codicons_list_tree.svg" ], "minVersionCode": 290, "license": "MIT", diff --git a/src/git/commands.ts b/src/git/commands.ts index 94d64bd..b0efe16 100644 --- a/src/git/commands.ts +++ b/src/git/commands.ts @@ -6,13 +6,13 @@ import { ApiRepository } from "./api/api1"; import { Branch, CommitOptions, ForcePushMode, GitErrorCodes, Ref, RefType, Remote, RemoteSourcePublisher, Status } from "./api/git"; import { item, showDialogMessage } from "./dialog"; import { UnifiedDiff } from "./diff"; -import { Git, GitError, Stash } from "./git"; +import { Git, GitError, Stash, Worktree } from "./git"; import { getInputHintResult, HintItem, InputHint, showInputHints } from "./hints"; import { LogOutputChannel } from "./logger"; import { Model } from "./model"; import { Repository, Resource, ResourceGroupType } from "./repository"; import { toGitUri } from "./uri"; -import { fromNow, getModeForFile, grep, isDescendant, pathEquals } from "./utils"; +import { coalesce, fromNow, getModeForFile, grep, isDescendant, pathEquals, toFullPath } from "./utils"; const fileBrowser = acode.require('fileBrowser'); const Url = acode.require('Url'); @@ -234,6 +234,46 @@ class RemoteTagDeleteItem extends RefItem { } } +class WorktreeItem implements HintItem { + get label(): string { return this.worktree.name; } + + get icon(): string { return 'vscode-codicons_list_tree'; } + + get description(): string | undefined { return this.worktree.path; } + + constructor(readonly worktree: Worktree) { } +} + +class WorktreeDeleteItem extends WorktreeItem { + override get description(): string | undefined { + if (!this.worktree.commitDetails) { + return undefined; + } + + return coalesce([ + this.worktree.detached ? 'detached' : this.worktree.ref.substring(11), + this.worktree.commitDetails.hash.substring(0, this.shortCommitLength), + this.worktree.commitDetails.message.split('\n')[0] + ]).join(' \u2022 '); + } + + get detail(): string { + return this.worktree.path; + } + + constructor(worktree: Worktree, private readonly shortCommitLength: number) { + super(worktree); + } + + async run(mainRepository: Repository): Promise { + if (!this.worktree.path) { + return; + } + + await mainRepository.deleteWorktree(this.worktree.path); + } +} + class MergeItem extends BranchItem { async run(repository: Repository): Promise { @@ -2086,6 +2126,41 @@ export class CommandCenter { } } + @command('Delete Worktree...', { repository: true }) + async deleteWorktree(repository: Repository): Promise { + const gitConfig = config.get('vcgit')!; + const commitShortHashLength = gitConfig.commitShortHashLength; + + const worktreeHints = async (): Promise => { + const worktrees = await repository.getWorktreeDetails(); + return worktrees.length === 0 + ? [{ label: 'ⓘ This repository has no worktrees.' }] + : worktrees.map(worktree => new WorktreeDeleteItem(worktree, commitShortHashLength)); + } + + const placeholder = 'Select a worktree to delete'; + const choice = await showInputHints(worktreeHints(), { placeholder }); + + if (choice instanceof WorktreeDeleteItem) { + await choice.run(repository); + } + } + + @command('Delete Worktree', { repository: true }) + async deleteWorktree2(repository: Repository): Promise { + if (!repository.dotGit.commonPath) { + return; + } + + const mainRepository = this.model.getRepository(Url.dirname(toFullPath(repository.dotGit.commonPath))); + if (!mainRepository) { + acode.alert('ERROR', 'You cannot delete the worktree you are currently in. Please switch to the main repository first.'); + return; + } + + await mainRepository.deleteWorktree(repository.root); + } + @command('Open Worktree', { repository: true }) openWorktree(repository: Repository): void { if (!repository) { diff --git a/src/git/git.ts b/src/git/git.ts index c452d54..5736194 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -1416,6 +1416,17 @@ export class Repository { await this.exec(args); } + async deleteWorktree(path: string, options?: { force?: boolean }): Promise { + const args = ['worktree', 'remove']; + + if (options?.force) { + args.push('--force'); + } + + args.push(path); + await this.exec(args); + } + async reset(treeish: string, hard: boolean = false): Promise { const args = ['reset', hard ? '--hard' : '--soft', treeish]; await this.exec(args); diff --git a/src/git/repository.ts b/src/git/repository.ts index e5bda7d..cc4bb94 100644 --- a/src/git/repository.ts +++ b/src/git/repository.ts @@ -12,6 +12,7 @@ import { CommandActions } from "./actions"; import { ApiRepository } from "./api/api1"; import { Branch, BranchQuery, Change, Commit, CommitOptions, FetchOptions, ForcePushMode, GitErrorCodes, LogOptions, Ref, RefType, Remote, RepositoryKind, Status } from "./api/git"; import { AutoFetcher } from "./autofetch"; +import { item, showDialogMessage } from "./dialog"; import { Repository as BaseRepository, GitError, LsTreeElement, PullOptions, RefQuery, Stash, Submodule, Worktree } from "./git"; import { LogOutputChannel } from "./logger"; import { Operation, OperationKind, OperationManager, OperationResult } from "./operation"; @@ -894,6 +895,40 @@ export class Repository implements IDisposable { return await this.run(Operation.GetRefs, () => this.repository.getRefs(query)); } + async getWorktrees(): Promise { + return await this.run(Operation.Worktree(true), () => this.repository.getWorktrees()); + } + + async getWorktreeDetails(): Promise { + return this.run(Operation.Worktree(true), async () => { + const worktrees = await this.repository.getWorktrees(); + if (worktrees.length === 0) { + return []; + } + + // Get refs for worktrees that point to a ref + const worktreeRefs = worktrees + .filter(worktree => !worktree.detached) + .map(worktree => worktree.ref); + + // Get the commit details for worktrees that point to a ref + const refs = await this.getRefs({ pattern: worktreeRefs, includeCommitDetails: true }); + + // Get the commit details for detached worktrees + const commits = await Promise.all(worktrees + .filter(worktree => worktree.detached) + .map(worktree => this.repository.getCommit(worktree.ref))); + + return worktrees.map(worktree => { + const commitDetails = worktree.detached + ? commits.find(commit => commit.hash === worktree.ref) + : refs.find(ref => `refs/heads/${ref.name}` === worktree.ref)?.commitDetails; + + return { ...worktree, commitDetails } satisfies Worktree; + }); + }); + } + async getRemoteRefs(remote: string, opts?: { heads?: boolean; tags?: boolean }): Promise { return await this.run(Operation.GetRemoteRefs, () => this.repository.getRemoteRefs(remote, opts)); } @@ -959,6 +994,33 @@ export class Repository implements IDisposable { }); } + async deleteWorktree(path: string, options?: { force?: boolean }): Promise { + await this.run(Operation.Worktree(false), async () => { + const worktree = this.repositoryResolver.getRepository(path); + + const deleteWorktree = async (options?: { force?: boolean }): Promise => { + await this.repository.deleteWorktree(path, options); + worktree?.dispose(); + }; + + try { + await deleteWorktree(); + } catch (err) { + if (err.gitErrorCode === GitErrorCodes.WorktreeContainsChanges) { + const forceDelete = item('Force Delete'); + const message = 'The worktree contains modified or untracked files. Do you want to force delete?'; + const choice = await showDialogMessage('WARNING', message, forceDelete); + if (choice === forceDelete) { + await deleteWorktree({ ...options, force: true }); + } + return; + } + + throw err; + } + }); + } + async checkout(treeish: string, opts?: { detached?: boolean; pullBeforeCheckout?: boolean }): Promise { const refLabel = opts?.detached ? getCommitShortHash(treeish) : treeish; diff --git a/src/git/utils.ts b/src/git/utils.ts index 7ff85b7..c6f6fec 100644 --- a/src/git/utils.ts +++ b/src/git/utils.ts @@ -20,6 +20,10 @@ export function assign(destination: T, ...sources: any[]): T { return destination; } +export function coalesce(array: ReadonlyArray): T[] { + return array.filter((e): e is T => !!e); +} + export namespace Versions { declare type VersionComparisonResult = -1 | 0 | 1; diff --git a/src/main.ts b/src/main.ts index 4feaefe..d9e3c6d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -244,6 +244,8 @@ async function initialize(baseUrl: string, options: Acode.PluginInitOptions): Pr acode.addIcon('git-commit', baseUrl + 'assets/git-commit.svg', { monochrome: true }); acode.addIcon('vscode-codicons_archive', baseUrl + 'assets/vscode-codicons_archive.svg', { monochrome: true }); acode.addIcon('vscode-codicons_worktree', baseUrl + 'assets/vscode-codicons_worktree.svg', { monochrome: true }); + acode.addIcon('vscode-codicons_list_tree', baseUrl + 'assets/vscode-codicons_list_tree.svg', { monochrome: true }); + const styles = tag('link', { rel: 'stylesheet', href: baseUrl + 'main.css' }); document.head.appendChild(styles); disposables.push(Disposable.toDisposable(() => styles.remove())); From ec39eca67d5528441b079d526771a41bff25a41b Mon Sep 17 00:00:00 2001 From: Diki Djatar <97192796+dikidjatar@users.noreply.github.com> Date: Mon, 8 Jun 2026 06:54:03 +0700 Subject: [PATCH 13/20] refactor: replace SVG icons with vscode codicons (#23) - Removed old SVG icons for repository, SCM, sync, and tag. - Added new SVG icons from vscode codicons for better consistency and visual appeal. - Updated references in plugin.json and various TypeScript files to use new icon names. - Ensured all icon usages in the codebase are aligned with the new codicon naming convention. --- dist/assets/branch.svg | 1 - dist/assets/cloud-upload.svg | 3 --- dist/assets/dash.svg | 3 --- dist/assets/debug-disconnect.svg | 3 --- dist/assets/git-commit.svg | 3 --- dist/assets/loading.svg | 3 --- dist/assets/refresh.svg | 4 ---- dist/assets/remote.svg | 3 --- dist/assets/repo.svg | 3 --- dist/assets/scm.svg | 3 --- dist/assets/sync.svg | 3 --- dist/assets/tag.svg | 3 --- dist/assets/vscode-codicons_cloud_upload.svg | 1 + .../vscode-codicons_debug_disconnect.svg | 1 + dist/assets/vscode-codicons_git_branch.svg | 1 + dist/assets/vscode-codicons_git_commit.svg | 1 + dist/assets/vscode-codicons_loading.svg | 1 + dist/assets/vscode-codicons_repo.svg | 1 + .../assets/vscode-codicons_source_control.svg | 1 + dist/assets/vscode-codicons_sync.svg | 1 + dist/assets/vscode-codicons_tag.svg | 1 + plugin.json | 21 ++++++++----------- src/git/actionButton.ts | 4 ++-- src/git/actions.ts | 16 +++++++------- src/git/commands.ts | 6 +++--- src/git/model.ts | 2 +- src/git/repository.ts | 2 +- src/main.ts | 14 ++++++------- src/scm/scm.ts | 4 ++-- src/scm/scmRepositoryRenderer.ts | 2 +- src/scm/style.scss | 4 +++- 31 files changed, 46 insertions(+), 73 deletions(-) delete mode 100644 dist/assets/branch.svg delete mode 100644 dist/assets/cloud-upload.svg delete mode 100644 dist/assets/dash.svg delete mode 100644 dist/assets/debug-disconnect.svg delete mode 100644 dist/assets/git-commit.svg delete mode 100644 dist/assets/loading.svg delete mode 100644 dist/assets/refresh.svg delete mode 100644 dist/assets/remote.svg delete mode 100644 dist/assets/repo.svg delete mode 100644 dist/assets/scm.svg delete mode 100644 dist/assets/sync.svg delete mode 100644 dist/assets/tag.svg create mode 100644 dist/assets/vscode-codicons_cloud_upload.svg create mode 100644 dist/assets/vscode-codicons_debug_disconnect.svg create mode 100644 dist/assets/vscode-codicons_git_branch.svg create mode 100644 dist/assets/vscode-codicons_git_commit.svg create mode 100644 dist/assets/vscode-codicons_loading.svg create mode 100644 dist/assets/vscode-codicons_repo.svg create mode 100644 dist/assets/vscode-codicons_source_control.svg create mode 100644 dist/assets/vscode-codicons_sync.svg create mode 100644 dist/assets/vscode-codicons_tag.svg diff --git a/dist/assets/branch.svg b/dist/assets/branch.svg deleted file mode 100644 index d586259..0000000 --- a/dist/assets/branch.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/dist/assets/cloud-upload.svg b/dist/assets/cloud-upload.svg deleted file mode 100644 index 1df1049..0000000 --- a/dist/assets/cloud-upload.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/dist/assets/dash.svg b/dist/assets/dash.svg deleted file mode 100644 index 556d335..0000000 --- a/dist/assets/dash.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/dist/assets/debug-disconnect.svg b/dist/assets/debug-disconnect.svg deleted file mode 100644 index ffc2089..0000000 --- a/dist/assets/debug-disconnect.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/dist/assets/git-commit.svg b/dist/assets/git-commit.svg deleted file mode 100644 index 43a7b90..0000000 --- a/dist/assets/git-commit.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/dist/assets/loading.svg b/dist/assets/loading.svg deleted file mode 100644 index df50d05..0000000 --- a/dist/assets/loading.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/dist/assets/refresh.svg b/dist/assets/refresh.svg deleted file mode 100644 index 9337fb2..0000000 --- a/dist/assets/refresh.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/dist/assets/remote.svg b/dist/assets/remote.svg deleted file mode 100644 index c69eb3d..0000000 --- a/dist/assets/remote.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/dist/assets/repo.svg b/dist/assets/repo.svg deleted file mode 100644 index dc063ee..0000000 --- a/dist/assets/repo.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/dist/assets/scm.svg b/dist/assets/scm.svg deleted file mode 100644 index 9db1d6d..0000000 --- a/dist/assets/scm.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/dist/assets/sync.svg b/dist/assets/sync.svg deleted file mode 100644 index 46501ac..0000000 --- a/dist/assets/sync.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/dist/assets/tag.svg b/dist/assets/tag.svg deleted file mode 100644 index b5d525c..0000000 --- a/dist/assets/tag.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/dist/assets/vscode-codicons_cloud_upload.svg b/dist/assets/vscode-codicons_cloud_upload.svg new file mode 100644 index 0000000..d542546 --- /dev/null +++ b/dist/assets/vscode-codicons_cloud_upload.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dist/assets/vscode-codicons_debug_disconnect.svg b/dist/assets/vscode-codicons_debug_disconnect.svg new file mode 100644 index 0000000..d423b72 --- /dev/null +++ b/dist/assets/vscode-codicons_debug_disconnect.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dist/assets/vscode-codicons_git_branch.svg b/dist/assets/vscode-codicons_git_branch.svg new file mode 100644 index 0000000..10d1c18 --- /dev/null +++ b/dist/assets/vscode-codicons_git_branch.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dist/assets/vscode-codicons_git_commit.svg b/dist/assets/vscode-codicons_git_commit.svg new file mode 100644 index 0000000..68653e1 --- /dev/null +++ b/dist/assets/vscode-codicons_git_commit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dist/assets/vscode-codicons_loading.svg b/dist/assets/vscode-codicons_loading.svg new file mode 100644 index 0000000..22cf343 --- /dev/null +++ b/dist/assets/vscode-codicons_loading.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dist/assets/vscode-codicons_repo.svg b/dist/assets/vscode-codicons_repo.svg new file mode 100644 index 0000000..733fadb --- /dev/null +++ b/dist/assets/vscode-codicons_repo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dist/assets/vscode-codicons_source_control.svg b/dist/assets/vscode-codicons_source_control.svg new file mode 100644 index 0000000..6786095 --- /dev/null +++ b/dist/assets/vscode-codicons_source_control.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dist/assets/vscode-codicons_sync.svg b/dist/assets/vscode-codicons_sync.svg new file mode 100644 index 0000000..518c93d --- /dev/null +++ b/dist/assets/vscode-codicons_sync.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dist/assets/vscode-codicons_tag.svg b/dist/assets/vscode-codicons_tag.svg new file mode 100644 index 0000000..a5676e3 --- /dev/null +++ b/dist/assets/vscode-codicons_tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugin.json b/plugin.json index fd20fb9..31f991f 100644 --- a/plugin.json +++ b/plugin.json @@ -7,18 +7,15 @@ "repository": "https://github.com/dikidjatar/acode-plugin-version-control-gitpro.git", "icon": "icon.png", "files": [ - "assets/scm.svg", - "assets/dash.svg", - "assets/branch.svg", - "assets/remote.svg", - "assets/repo.svg", - "assets/sync.svg", - "assets/cloud-upload.svg", - "assets/tag.svg", - "assets/debug-disconnect.svg", - "assets/refresh.svg", - "assets/loading.svg", - "assets/git-commit.svg", + "assets/vscode-codicons_source_control.svg", + "assets/vscode-codicons_loading.svg", + "assets/vscode-codicons_tag.svg", + "assets/vscode-codicons_sync.svg", + "assets/vscode-codicons_debug_disconnect.svg", + "assets/vscode-codicons_git_commit.svg", + "assets/vscode-codicons_repo.svg", + "assets/vscode-codicons_cloud_upload.svg", + "assets/vscode-codicons_git_branch.svg", "assets/vscode-codicons_archive.svg", "assets/vscode-codicons_worktree.svg", "assets/vscode-codicons_list_tree.svg" diff --git a/src/git/actionButton.ts b/src/git/actionButton.ts index 30d3a7f..b15c91d 100644 --- a/src/git/actionButton.ts +++ b/src/git/actionButton.ts @@ -188,7 +188,7 @@ export class ActionButton { if (this.state.HEAD?.type === RefType.Tag || !this.state.HEAD?.name || this.state.HEAD?.upstream || this.state.isCommitInProgress || this.state.isMergeInProgress || this.state.isRebaseInProgress) { return undefined; } // Button icon - const icon = this.state.isSyncInProgress ? '$(sync~spin)' : '$(cloud-upload)'; + const icon = this.state.isSyncInProgress ? '$(vscode-codicons_sync~spin)' : '$(vscode-codicons_cloud_upload)'; return { command: { @@ -208,7 +208,7 @@ export class ActionButton { const ahead = this.state.HEAD.ahead ? ` ${this.state.HEAD.ahead}↑` : ''; const behind = this.state.HEAD.behind ? ` ${this.state.HEAD.behind}↓` : ''; - const icon = this.state.isSyncInProgress ? '$(sync~spin)' : '$(sync)'; + const icon = this.state.isSyncInProgress ? '$(vscode-codicons_sync~spin)' : '$(vscode-codicons_sync)'; return { command: { diff --git a/src/git/actions.ts b/src/git/actions.ts index 024be3a..c6e9794 100644 --- a/src/git/actions.ts +++ b/src/git/actions.ts @@ -60,18 +60,18 @@ class CheckoutCommandAction { } if (this.state.isCheckoutRunning) { - return '$(loading~spin)'; + return '$(vscode-codicons_loading~spin)'; } if (this.repository.HEAD.type === RefType.Head && this.repository.HEAD.name) { - return '$(branch)'; + return '$(vscode-codicons_git_branch)'; } if (this.repository.HEAD.type === RefType.Tag) { - return '$(tag)'; + return '$(vscode-codicons_tag)'; } - return '$(git-commit)'; + return '$(vscode-codicons_git_commit)'; } private onDidChangeOperations(): void { @@ -181,13 +181,13 @@ class SyncCommandAction { return { id: command, - title: `$(cloud-upload)`, + title: `$(vscode-codicons_cloud_upload)`, arguments: [this.repository.sourceControl] } } -`` + `` const HEAD = this.state.HEAD; - let icon = 'sync'; + let icon = 'vscode-codicons_sync'; let text = ''; let command = ''; @@ -199,7 +199,7 @@ class SyncCommandAction { command = 'git.sync'; } else { - icon = 'cloud-upload'; + icon = 'vscode-codicons_cloud_upload'; command = 'git.publish'; } } else { diff --git a/src/git/commands.ts b/src/git/commands.ts index b0efe16..991c7c0 100644 --- a/src/git/commands.ts +++ b/src/git/commands.ts @@ -46,7 +46,7 @@ class CreateBranchFromItem extends CheckoutCommandItem { class CheckoutDetachedItem extends CheckoutCommandItem { get label(): string { return 'Checkout detached...'; } - get icon(): string { return 'debug-disconnect'; } + get icon(): string { return 'vscode-codicons_debug_disconnect'; } } class RefItemSeparator implements HintItem { @@ -85,9 +85,9 @@ class RefItem implements HintItem { get icon(): string { switch (this.ref.type) { - case RefType.Head: return 'branch'; + case RefType.Head: return 'vscode-codicons_git_branch'; case RefType.RemoteHead: return 'cloud'; - case RefType.Tag: return 'tag'; + case RefType.Tag: return 'vscode-codicons_tag'; } } diff --git a/src/git/model.ts b/src/git/model.ts index d6f0fad..f22da83 100644 --- a/src/git/model.ts +++ b/src/git/model.ts @@ -33,7 +33,7 @@ class RepositoryHint implements HintItem { .join(' '); } - @memoize get icon(): string { return 'repo'; } + @memoize get icon(): string { return 'vscode-codicons_repo'; } constructor(public readonly repository: Repository) { } } diff --git a/src/git/repository.ts b/src/git/repository.ts index cc4bb94..ad00e8f 100644 --- a/src/git/repository.ts +++ b/src/git/repository.ts @@ -689,7 +689,7 @@ export class Repository implements IDisposable { ? 'vscode-codicons_archive' : repository.kind === 'worktree' ? 'vscode-codicons_worktree' - : 'repo' + : 'vscode-codicons_repo' this._sourceControl = scm.createSourceControl('git', 'Git', this.repository.root, icon); this._sourceControl.contextValue = repository.kind; diff --git a/src/main.ts b/src/main.ts index d9e3c6d..f8a9b11 100644 --- a/src/main.ts +++ b/src/main.ts @@ -235,13 +235,13 @@ async function initialize(baseUrl: string, options: Acode.PluginInitOptions): Pr await config.init('vcgit', defaultGitConfig); disposables.push(config); - acode.addIcon('branch', baseUrl + 'assets/branch.svg', { monochrome: true }); - acode.addIcon('sync', baseUrl + 'assets/sync.svg', { monochrome: true }); - acode.addIcon('cloud-upload', baseUrl + 'assets/cloud-upload.svg', { monochrome: true }); - acode.addIcon('debug-disconnect', baseUrl + 'assets/debug-disconnect.svg', { monochrome: true }); - acode.addIcon('tag', baseUrl + 'assets/tag.svg', { monochrome: true }); - acode.addIcon('loading', baseUrl + 'assets/loading.svg', { monochrome: true }); - acode.addIcon('git-commit', baseUrl + 'assets/git-commit.svg', { monochrome: true }); + acode.addIcon('vscode-codicons_git_branch', baseUrl + 'assets/vscode-codicons_git_branch.svg', { monochrome: true }); + acode.addIcon('vscode-codicons_cloud_upload', baseUrl + 'assets/vscode-codicons_cloud_upload.svg', { monochrome: true }); + acode.addIcon('vscode-codicons_sync', baseUrl + 'assets/vscode-codicons_sync.svg', { monochrome: true }); + acode.addIcon('vscode-codicons_tag', baseUrl + 'assets/vscode-codicons_tag.svg', { monochrome: true }); + acode.addIcon('vscode-codicons_loading', baseUrl + 'assets/vscode-codicons_loading.svg', { monochrome: true }); + acode.addIcon('vscode-codicons_debug_disconnect', baseUrl + 'assets/vscode-codicons_debug_disconnect.svg', { monochrome: true }); + acode.addIcon('vscode-codicons_git_commit', baseUrl + 'assets/vscode-codicons_git_commit.svg', { monochrome: true }); acode.addIcon('vscode-codicons_archive', baseUrl + 'assets/vscode-codicons_archive.svg', { monochrome: true }); acode.addIcon('vscode-codicons_worktree', baseUrl + 'assets/vscode-codicons_worktree.svg', { monochrome: true }); acode.addIcon('vscode-codicons_list_tree', baseUrl + 'assets/vscode-codicons_list_tree.svg', { monochrome: true }); diff --git a/src/scm/scm.ts b/src/scm/scm.ts index d6c64d3..7bc8584 100644 --- a/src/scm/scm.ts +++ b/src/scm/scm.ts @@ -703,8 +703,8 @@ export namespace scm { const appSettings = acode.require('settings'); appSettings.uiSettings['scm-settings'] = scmSettings(); - acode.addIcon('scm', baseUrl + 'assets/scm.svg', { monochrome: true }); - acode.addIcon('repo', baseUrl + 'assets/repo.svg', { monochrome: true }); + acode.addIcon('scm', baseUrl + 'assets/vscode-codicons_source_control.svg', { monochrome: true }); + acode.addIcon('vscode-codicons_repo', baseUrl + 'assets/vscode-codicons_repo.svg', { monochrome: true }) const scmService = new SCMService(); const scmViewService = new SCMViewService(scmService); diff --git a/src/scm/scmRepositoryRenderer.ts b/src/scm/scmRepositoryRenderer.ts index f75342a..92c9872 100644 --- a/src/scm/scmRepositoryRenderer.ts +++ b/src/scm/scmRepositoryRenderer.ts @@ -177,7 +177,7 @@ export class RepositoryRenderer implements IListRenderer Date: Mon, 8 Jun 2026 10:40:34 +0700 Subject: [PATCH 14/20] feat: add selected icon for repository in SCM and update icon handling (#24) --- dist/assets/vscode-codicons_repo_selected.svg | 1 + src/scm/scm.ts | 3 +- src/scm/scmRepositoryRenderer.ts | 31 ++++++++++++++++--- 3 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 dist/assets/vscode-codicons_repo_selected.svg diff --git a/dist/assets/vscode-codicons_repo_selected.svg b/dist/assets/vscode-codicons_repo_selected.svg new file mode 100644 index 0000000..cb83bb9 --- /dev/null +++ b/dist/assets/vscode-codicons_repo_selected.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/scm/scm.ts b/src/scm/scm.ts index 7bc8584..21c86c5 100644 --- a/src/scm/scm.ts +++ b/src/scm/scm.ts @@ -704,7 +704,8 @@ export namespace scm { appSettings.uiSettings['scm-settings'] = scmSettings(); acode.addIcon('scm', baseUrl + 'assets/vscode-codicons_source_control.svg', { monochrome: true }); - acode.addIcon('vscode-codicons_repo', baseUrl + 'assets/vscode-codicons_repo.svg', { monochrome: true }) + acode.addIcon('vscode-codicons_repo', baseUrl + 'assets/vscode-codicons_repo.svg', { monochrome: true }); + acode.addIcon('vscode-codicons_repo_selected', baseUrl + 'assets/vscode-codicons_repo_selected.svg', { monochrome: true }); const scmService = new SCMService(); const scmViewService = new SCMViewService(scmService); diff --git a/src/scm/scmRepositoryRenderer.ts b/src/scm/scmRepositoryRenderer.ts index 92c9872..9f96b62 100644 --- a/src/scm/scmRepositoryRenderer.ts +++ b/src/scm/scmRepositoryRenderer.ts @@ -144,6 +144,7 @@ export interface RepositoryTemplate { readonly icon: HTMLElement; readonly label: HTMLElement; readonly action: RepositoryAction; + readonly elementDisposables: DisposableStore; readonly templateDisposables: DisposableStore; } @@ -170,18 +171,40 @@ export class RepositoryRenderer implements IListRenderer { + const isVisible = this.scmViewService.isVisible(repository); + const icon = repository.provider.icon + ? repository.provider.icon + : 'vscode-codicons_repo'; + + const showSelectedIcon = icon === 'vscode-codicons_repo' && isVisible && this.scmViewService.repositories.length > 1; + + templateData.icon.className = showSelectedIcon + ? `icon ${icon}_selected` + : `icon ${icon}`; + } + + // Re-evaluate the icon whenever the visible repository set changes so + // the selected/unselected state is reflected immediately on click. + templateData.elementDisposables.add(this.scmViewService.onDidChangeVisibleRepositories(updateIcon)); + updateIcon(); + templateData.label.textContent = repository.provider.name; templateData.action.setRepository(repository); } + disposeElement(element: ISCMRepository, index: number, templateData: RepositoryTemplate): void { + templateData.elementDisposables.clear(); + } + disposeTemplate(templateData: RepositoryTemplate): void { templateData.templateDisposables.dispose(); } From 64ece070f53b1b59f58644bcb4343991b138b617 Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Mon, 8 Jun 2026 14:36:56 +0700 Subject: [PATCH 15/20] feat: implement touch swipe-right selection for list items --- src/base/list.ts | 160 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/src/base/list.ts b/src/base/list.ts index e12d4e5..89ccf97 100644 --- a/src/base/list.ts +++ b/src/base/list.ts @@ -107,8 +107,41 @@ interface IListOptions { readonly styleController?: (suffix: string) => IStyleController; } +interface TouchState { + /** + * clientX of the initial touch point. + */ + startX: number; + /** + * clientY of the initial touch point. + */ + startY: number; + /** + * List index of the item that was touched, or undefined for empty space. + */ + index: number | undefined; + /** + * True once the horizontal swipe crosses SWIPE_THRESHOLD_X. + */ + isDragging: boolean; + /** + * The li.tile DOM node used for visual feedback, or null. + */ + feedbackEl: HTMLElement | null; +} + class List implements IDisposable { private static InstanceCount = 0; + + /** + * Minimum horizontal pixels the finger must travel to confirm a swipe-select. + */ + private static readonly SWIPE_THRESHOLD_X = 50; + /** + * Maximum vertical drift allowed before the swipe is cancelled. + */ + private static readonly SWIPE_MAX_Y = 40; + readonly domId = `list_id_${++List.InstanceCount}`; readonly domNode: HTMLElement; readonly scrollableElement: HTMLUListElement; @@ -133,6 +166,8 @@ class List implements IDisposable { return this.items.reduce((total, item) => total + item.size, 0); } + private touchState: TouchState | undefined = undefined; + private readonly _onDidChangeContentHeight = this.disposables.add(new Emitter()); readonly onDidChangeContentHeight: Event = this._onDidChangeContentHeight.event; @@ -159,6 +194,14 @@ class List implements IDisposable { private readonly _onPointer = this.disposables.add(new Emitter>()); get onPointer(): Event> { return this._onPointer.event; } + private readonly _onDidSwipeRightSelect = this.disposables.add(new Emitter>()); + /** + * Fires when the user performs a right-swipe gesture on a list item. + * The event carries the single item that was swiped so callers can + * toggle its selection without replacing the full selection set. + */ + readonly onDidSwipeRightSelect: Event> = this._onDidSwipeRightSelect.event; + constructor( container: HTMLElement, private delegate: IListDelegate, @@ -195,6 +238,14 @@ class List implements IDisposable { this.domNode.oncontextmenu = (e) => { this._onContextMenu.fire(this.toMouseEvent(e)); }; + + // Touch swipe-right listeners + // touchmove must be non-passive so we can call preventDefault() to + // suppress vertical scrolling while the user is swiping horizontally. + Event.fromDOMEvent(this.scrollableElement, 'touchstart', { passive: true })(this.onTouchStart, this, this.disposables); + Event.fromDOMEvent(this.scrollableElement, 'touchmove', { passive: true })(this.onTouchMove, this, this.disposables); + Event.fromDOMEvent(this.scrollableElement, 'touchend', { passive: true })(this.onTouchEnd, this, this.disposables); + Event.fromDOMEvent(this.scrollableElement, 'touchcancel', { passive: true })(this.onTouchCancel, this, this.disposables); } splice(start: number, deleteCount: number, toInsert: readonly T[]): void { @@ -441,6 +492,115 @@ class List implements IDisposable { this._onPointer.fire(e); } + // Touch swipe-right handlers + + /** + * Records the finger's starting position and which item it landed on. + * Only single-finger touches are tracked. Multi-touch resets state. + */ + private onTouchStart(e: TouchEvent): void { + if (e.touches.length !== 1) { + this.onTouchCancel(); + return; + } + + const touch = e.touches[0]; + const index = this.getItemIndexFromEventTarget(e.target); + this.touchState = { + startX: touch.clientX, + startY: touch.clientY, + index, + isDragging: false, + feedbackEl: index !== undefined + ? (this.items[index]?.row?.domNode ?? null) + : null + } + } + + private onTouchMove(e: TouchEvent): void { + if (!this.touchState || this.touchState.index === undefined) { + return; + } + + const touch = e.touches[0]; + const deltaX = touch.clientX - this.touchState.startX; + const deltaY = Math.abs(touch.clientY - this.touchState.startY); + const totalDelta = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + // Cancel when the gesture is primarily vertical (after enough movement + // to reliably determine intent). + if (deltaY > List.SWIPE_MAX_Y || (totalDelta > 15 && deltaY > deltaX * 1.2)) { + if (this.touchState.feedbackEl) { + this.touchState.feedbackEl.style.transform = ''; + this.touchState.feedbackEl.classList.remove('swipe-selecting'); + } + this.touchState.isDragging = false; + return; + } + + if (deltaX > 0) { + // Suppress vertical scroll only when the swipe is clearly horizontal + if (deltaX > deltaY * 1.5) { + e.preventDefault(); + } + + // Translate the tile capped so it never slides too far off screen + const cappedX = Math.min(deltaX * 0.4, 24); + const overThreshold = deltaX >= List.SWIPE_THRESHOLD_X; + + if (this.touchState.feedbackEl) { + this.touchState.feedbackEl.style.transform = `translateX(${cappedX}px)`; + this.touchState.feedbackEl.classList.toggle('swipe-selecting', overThreshold); + } + + if (overThreshold) { + this.touchState.isDragging = true; + } + } + } + + private onTouchEnd(e: TouchEvent): void { + if (!this.touchState) { + return; + } + + const { feedbackEl, isDragging, index } = this.touchState; + + if (feedbackEl) { + feedbackEl.style.transform = ''; + feedbackEl.classList.remove('swipe-selecting'); + } + + if (isDragging && index !== undefined) { + // Block the synthetic click that mobile browsers fire after touchend, + // which would invoke onViewPointer and undo the multi-select. + e.preventDefault(); + + // Brief flash to confirm the toggle + if (feedbackEl) { + feedbackEl.classList.add('swipe-select-flash'); + setTimeout(() => feedbackEl.classList.remove('swipe-select-flash'), 380); + } + + const item = this.items[index]; + if (item) { + this._onDidSwipeRightSelect.fire({ + indexes: [index], + elements: [item.element], + browserEvent: e + }); + } + } + } + + private onTouchCancel(): void { + if (this.touchState?.feedbackEl) { + this.touchState.feedbackEl.style.transform = ''; + this.touchState.feedbackEl.classList.remove('swipe-selecting'); + } + this.touchState = undefined; + } + style(styles: IListStyles): void { this.styleController.style(styles); } From b7b4edb00379e90d50790ee306e794697881a225 Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Mon, 8 Jun 2026 14:38:00 +0700 Subject: [PATCH 16/20] feat: add swipe-right selection functionality for repositories in SCM view --- src/scm/scmRepositoriesView.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/scm/scmRepositoriesView.ts b/src/scm/scmRepositoriesView.ts index 58c4ec4..975344e 100644 --- a/src/scm/scmRepositoriesView.ts +++ b/src/scm/scmRepositoriesView.ts @@ -1,9 +1,9 @@ import { Disposable, DisposableMap, DisposableStore, IDisposable } from "../base/disposable"; import { CollapsableList, IListContextMenuEvent, IListDelegate, IListEvent, unthemedListStyles } from "../base/list"; -import { IView } from "./views"; import { RepositoryRenderer } from "./scmRepositoryRenderer"; import { ISCMCommandService, ISCMMenuService, ISCMRepository, ISCMService, ISCMViewService } from "./types"; import { isSCMRepository } from "./utils"; +import { IView } from "./views"; class ListDelegate implements IListDelegate { @@ -76,6 +76,7 @@ export class ScmRepositoriesView extends Disposable.Disposable implements IView this._register(this.list); this._register(this.list.onDidChangeSelection(this.onListSelectionChange, this)); this._register(this.list.onContextMenu(this.onListContextMenu, this)); + this._register(this.list.onDidSwipeRightSelect(this.onListSwipeRightSelect, this)); } private onDidAddRepository(repository: ISCMRepository): void { const disposable = new DisposableStore(); @@ -139,6 +140,33 @@ export class ScmRepositoriesView extends Disposable.Disposable implements IView } } + private onListSwipeRightSelect(e: IListEvent): void { + if (e.elements.length === 0) { + return; + } + + const repository = e.elements[0]; + if (!isSCMRepository(repository)) { + return; + } + + const scrollTop = this.list.scrollTop; + const currentlyVisible = this.scmViewService.visibleRepositories; + const isVisible = this.scmViewService.isVisible(repository); + + if (isVisible) { + // Deselect but keep at least one repository visible at all times. + if (currentlyVisible.length > 1) { + this.scmViewService.visibleRepositories = currentlyVisible.filter(r => r !== repository); + } + } else { + // Add to the visible + this.scmViewService.visibleRepositories = [...currentlyVisible, repository]; + } + + this.list.scrollTop = scrollTop; + } + private updateListSelection(): void { const oldSelection = this.list.getSelectedElements(); const oldSet = new Set(oldSelection); From 666ca6e06f4f498bf1c579acf3e205c3eb7163df Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Mon, 8 Jun 2026 15:26:15 +0700 Subject: [PATCH 17/20] feat: add supported swipe-right options --- src/base/list.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/base/list.ts b/src/base/list.ts index 89ccf97..2de7aa1 100644 --- a/src/base/list.ts +++ b/src/base/list.ts @@ -13,6 +13,7 @@ export interface IListRenderer { export interface IListDelegate { getHeight(element: T): number; getTemplateId(element: T): string; + isSupportedSwipeRight?(element: T): boolean; } export interface IListBrowserMouseEvent extends MouseEvent { @@ -506,10 +507,13 @@ class List implements IDisposable { const touch = e.touches[0]; const index = this.getItemIndexFromEventTarget(e.target); + const swipeIndex = (typeof index !== 'undefined' && this.isSwipeRightSupported(index)) + ? index + : undefined; this.touchState = { startX: touch.clientX, startY: touch.clientY, - index, + index: swipeIndex, isDragging: false, feedbackEl: index !== undefined ? (this.items[index]?.row?.domNode ?? null) @@ -601,6 +605,12 @@ class List implements IDisposable { this.touchState = undefined; } + private isSwipeRightSupported(index: number): boolean { + const item = this.items[index]; + return typeof item !== undefined && + (this.delegate.isSupportedSwipeRight?.(item.element) ?? false); + } + style(styles: IListStyles): void { this.styleController.style(styles); } From d97ca9074350f5de8ae1e3e2a9e28c0a060cf59d Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Mon, 8 Jun 2026 15:26:58 +0700 Subject: [PATCH 18/20] feat(scm-view): add isSupportedSwipeRight for list delegate --- src/scm/scmRepositoriesView.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/scm/scmRepositoriesView.ts b/src/scm/scmRepositoriesView.ts index 975344e..b872a74 100644 --- a/src/scm/scmRepositoriesView.ts +++ b/src/scm/scmRepositoriesView.ts @@ -14,6 +14,10 @@ class ListDelegate implements IListDelegate { getTemplateId(element: ISCMRepository): string { return RepositoryRenderer.TEMPLATE_ID; } + + isSupportedSwipeRight(element: ISCMRepository): boolean { + return isSCMRepository(element); + } } export class ScmRepositoriesView extends Disposable.Disposable implements IView { From 710c891540104f36e69c9f5855da327f4458ae0d Mon Sep 17 00:00:00 2001 From: dikidjatar Date: Mon, 8 Jun 2026 17:41:53 +0700 Subject: [PATCH 19/20] feat: adding selection logic in ListDelegate --- src/scm/scmRepositoriesView.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/scm/scmRepositoriesView.ts b/src/scm/scmRepositoriesView.ts index b872a74..c51b442 100644 --- a/src/scm/scmRepositoriesView.ts +++ b/src/scm/scmRepositoriesView.ts @@ -7,6 +7,10 @@ import { IView } from "./views"; class ListDelegate implements IListDelegate { + constructor( + private readonly getSelectedRepositories: () => readonly ISCMRepository[] + ) { } + getHeight(element: ISCMRepository): number { return 34; } @@ -16,6 +20,10 @@ class ListDelegate implements IListDelegate { } isSupportedSwipeRight(element: ISCMRepository): boolean { + const selectedRepositories = this.getSelectedRepositories(); + if (selectedRepositories.length === 1) { + return isSCMRepository(element) && selectedRepositories[0] !== element; + } return isSCMRepository(element); } } @@ -66,7 +74,7 @@ export class ScmRepositoriesView extends Disposable.Disposable implements IView this.list = new CollapsableList( 'Repositories', container, - new ListDelegate(), + new ListDelegate(() => this.scmViewService.visibleRepositories), [new RepositoryRenderer(false, this.scmViewService, this.scmCommandService, this.scmMenuService)], { allCaps: true, From 9c492f743dfd9def5c847fac2d685c0766a653e2 Mon Sep 17 00:00:00 2001 From: Diki Djatar <97192796+dikidjatar@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:09:35 +0700 Subject: [PATCH 20/20] Merge pull request #26 from dikidjatar/fix/swipe-right-overflow fix(list): swipe-right elements movement issue --- src/base/list.ts | 77 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/src/base/list.ts b/src/base/list.ts index 2de7aa1..0059919 100644 --- a/src/base/list.ts +++ b/src/base/list.ts @@ -126,9 +126,13 @@ interface TouchState { */ isDragging: boolean; /** - * The li.tile DOM node used for visual feedback, or null. + * The `li.tile` element */ - feedbackEl: HTMLElement | null; + tileElement: HTMLElement | null; + /** + * All direct children of `tileEl` + */ + feedbackElements: HTMLElement[]; } class List implements IDisposable { @@ -244,9 +248,22 @@ class List implements IDisposable { // touchmove must be non-passive so we can call preventDefault() to // suppress vertical scrolling while the user is swiping horizontally. Event.fromDOMEvent(this.scrollableElement, 'touchstart', { passive: true })(this.onTouchStart, this, this.disposables); - Event.fromDOMEvent(this.scrollableElement, 'touchmove', { passive: true })(this.onTouchMove, this, this.disposables); + Event.fromDOMEvent(this.scrollableElement, 'touchmove', { passive: false })(this.onTouchMove, this, this.disposables); Event.fromDOMEvent(this.scrollableElement, 'touchend', { passive: true })(this.onTouchEnd, this, this.disposables); Event.fromDOMEvent(this.scrollableElement, 'touchcancel', { passive: true })(this.onTouchCancel, this, this.disposables); + + // Inject swipe-selection styles scoped to this list instance. + // + // the translateX transform is applied to the tile's *inner child* + // not the tile itself. overflow:hidden clips the shifted child + // content inside the tile boundary, so the tile's + // own layout box never changes and the ul.scroll's scrollable area is never expanded + // which is what was causing the spurious scrollbars. + const swipeStyle = createStyleSheet(this.domNode); + swipeStyle.textContent = [ + `.${this.domId} .tile { overflow: hidden; }`, + `.${this.domId} .tile > * { transition: transform .06s linear; }`, + ].join('\n'); } splice(start: number, deleteCount: number, toInsert: readonly T[]): void { @@ -510,15 +527,19 @@ class List implements IDisposable { const swipeIndex = (typeof index !== 'undefined' && this.isSwipeRightSupported(index)) ? index : undefined; + + const tileElement = index !== undefined ? (this.items[index]?.row?.domNode ?? null) : null; + // Collect ALL direct children so the entire tile content moves as one unit. + const feedbackEls = tileElement ? (Array.from(tileElement.children) as HTMLElement[]) : []; + this.touchState = { startX: touch.clientX, startY: touch.clientY, index: swipeIndex, isDragging: false, - feedbackEl: index !== undefined - ? (this.items[index]?.row?.domNode ?? null) - : null - } + tileElement: tileElement, + feedbackElements: feedbackEls + }; } private onTouchMove(e: TouchEvent): void { @@ -534,9 +555,11 @@ class List implements IDisposable { // Cancel when the gesture is primarily vertical (after enough movement // to reliably determine intent). if (deltaY > List.SWIPE_MAX_Y || (totalDelta > 15 && deltaY > deltaX * 1.2)) { - if (this.touchState.feedbackEl) { - this.touchState.feedbackEl.style.transform = ''; - this.touchState.feedbackEl.classList.remove('swipe-selecting'); + for (const element of this.touchState.feedbackElements) { + element.style.transform = ''; + } + if (this.touchState.tileElement) { + this.touchState.tileElement.classList.remove('swipe-selecting'); } this.touchState.isDragging = false; return; @@ -552,9 +575,11 @@ class List implements IDisposable { const cappedX = Math.min(deltaX * 0.4, 24); const overThreshold = deltaX >= List.SWIPE_THRESHOLD_X; - if (this.touchState.feedbackEl) { - this.touchState.feedbackEl.style.transform = `translateX(${cappedX}px)`; - this.touchState.feedbackEl.classList.toggle('swipe-selecting', overThreshold); + for (const element of this.touchState.feedbackElements) { + element.style.transform = `translateX(${cappedX}px)`; + } + if (this.touchState.tileElement) { + this.touchState.tileElement.classList.toggle('swipe-selecting', overThreshold); } if (overThreshold) { @@ -568,11 +593,13 @@ class List implements IDisposable { return; } - const { feedbackEl, isDragging, index } = this.touchState; + const { feedbackElements, tileElement, isDragging, index } = this.touchState; - if (feedbackEl) { - feedbackEl.style.transform = ''; - feedbackEl.classList.remove('swipe-selecting'); + for (const element of feedbackElements) { + element.style.transform = ''; + } + if (tileElement) { + tileElement.classList.remove('swipe-selecting'); } if (isDragging && index !== undefined) { @@ -581,9 +608,9 @@ class List implements IDisposable { e.preventDefault(); // Brief flash to confirm the toggle - if (feedbackEl) { - feedbackEl.classList.add('swipe-select-flash'); - setTimeout(() => feedbackEl.classList.remove('swipe-select-flash'), 380); + if (tileElement) { + tileElement.classList.add('swipe-select-flash'); + setTimeout(() => tileElement.classList.remove('swipe-select-flash'), 380); } const item = this.items[index]; @@ -598,9 +625,13 @@ class List implements IDisposable { } private onTouchCancel(): void { - if (this.touchState?.feedbackEl) { - this.touchState.feedbackEl.style.transform = ''; - this.touchState.feedbackEl.classList.remove('swipe-selecting'); + if (this.touchState) { + for (const el of this.touchState.feedbackElements) { + el.style.transform = ''; + } + if (this.touchState.tileElement) { + this.touchState.tileElement.classList.remove('swipe-selecting'); + } } this.touchState = undefined; }