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_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_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_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/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_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/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/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/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/plugin.json b/plugin.json
index d41d644..31f991f 100644
--- a/plugin.json
+++ b/plugin.json
@@ -7,18 +7,18 @@
"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"
],
"minVersionCode": 290,
"license": "MIT",
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.");
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/base/list.ts b/src/base/list.ts
index e12d4e5..0059919 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 {
@@ -107,8 +108,45 @@ 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` element
+ */
+ tileElement: HTMLElement | null;
+ /**
+ * All direct children of `tileEl`
+ */
+ feedbackElements: HTMLElement[];
+}
+
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 +171,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 +199,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 +243,27 @@ 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: 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 {
@@ -441,6 +510,138 @@ 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);
+ 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,
+ tileElement: tileElement,
+ feedbackElements: feedbackEls
+ };
+ }
+
+ 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)) {
+ 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;
+ }
+
+ 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;
+
+ 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) {
+ this.touchState.isDragging = true;
+ }
+ }
+ }
+
+ private onTouchEnd(e: TouchEvent): void {
+ if (!this.touchState) {
+ return;
+ }
+
+ const { feedbackElements, tileElement, isDragging, index } = this.touchState;
+
+ for (const element of feedbackElements) {
+ element.style.transform = '';
+ }
+ if (tileElement) {
+ tileElement.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 (tileElement) {
+ tileElement.classList.add('swipe-select-flash');
+ setTimeout(() => tileElement.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) {
+ for (const el of this.touchState.feedbackElements) {
+ el.style.transform = '';
+ }
+ if (this.touchState.tileElement) {
+ this.touchState.tileElement.classList.remove('swipe-selecting');
+ }
+ }
+ 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);
}
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/api/git.d.ts b/src/git/api/git.d.ts
index 5d147ef..a9f0787 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,
@@ -108,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/commands.ts b/src/git/commands.ts
index f276fac..991c7c0 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, 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');
@@ -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';
}
}
@@ -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 {
@@ -1925,13 +1965,212 @@ 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.openWorktree(worktreeRepository);
+ }
+ }
+
+ @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) {
+ 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')!;
@@ -2356,22 +2595,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 +2899,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')
diff --git a/src/git/git.ts b/src/git/git.ts
index f51471f..5736194 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://${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';
+
return {
+ isBare,
path: dotGitPath,
commonPath: commonDotGitPath !== dotGitPath ? commonDotGitPath : undefined,
superProjectPath: superProjectPath ? superProjectPath : undefined
@@ -848,12 +855,24 @@ 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;
readonly autoStash?: boolean;
}
+export interface Worktree extends ApiWorktree {
+ readonly commitDetails?: ApiCommit;
+}
+
export class Repository {
private _isUsingRefTable = false;
@@ -864,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 {
@@ -1372,6 +1400,33 @@ 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 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);
@@ -1946,6 +2001,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: normalizeGitdir(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/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 {
diff --git a/src/git/model.ts b/src/git/model.ts
index 2ecb220..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) { }
}
@@ -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}`);
@@ -505,6 +523,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 +546,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/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..ad00e8f 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";
@@ -9,17 +10,17 @@ 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 { FileDecoration } from "../base/decorationService";
-import { Repository as BaseRepository, GitError, LsTreeElement, PullOptions, RefQuery, Stash, Submodule } from "./git";
+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";
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 +571,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) {
@@ -632,6 +638,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;
@@ -675,7 +685,14 @@ 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'
+ : 'vscode-codicons_repo'
+
+ this._sourceControl = scm.createSourceControl('git', 'Git', this.repository.root, icon);
+ this._sourceControl.contextValue = repository.kind;
this.disposables.push(this._sourceControl);
this.updateInputBoxPlaceholder();
@@ -878,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));
}
@@ -910,6 +961,66 @@ 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 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;
@@ -1559,10 +1670,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 +1682,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/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 b57d758..f8a9b11 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() {
@@ -233,13 +235,17 @@ 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 });
+
const styles = tag('link', { rel: 'stylesheet', href: baseUrl + 'main.css' });
document.head.appendChild(styles);
disposables.push(Disposable.toDisposable(() => styles.remove()));
@@ -491,6 +497,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 +933,28 @@ function initializeMenus(logger: LogOutputChannel): void {
enablement: () => !App.getContext('git.operationInProgress')
}
]);
+
+ // 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'),
+ 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'
+ }
+ ]);
}
function gitPluginSettings(): Acode.PluginSettings {
@@ -1338,6 +1372,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/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..21c86c5 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();
@@ -679,8 +703,9 @@ 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 });
+ 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/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..c51b442 100644
--- a/src/scm/scmRepositoriesView.ts
+++ b/src/scm/scmRepositoriesView.ts
@@ -1,12 +1,16 @@
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 {
+ constructor(
+ private readonly getSelectedRepositories: () => readonly ISCMRepository[]
+ ) { }
+
getHeight(element: ISCMRepository): number {
return 34;
}
@@ -14,6 +18,14 @@ class ListDelegate implements IListDelegate {
getTemplateId(element: ISCMRepository): string {
return RepositoryRenderer.TEMPLATE_ID;
}
+
+ isSupportedSwipeRight(element: ISCMRepository): boolean {
+ const selectedRepositories = this.getSelectedRepositories();
+ if (selectedRepositories.length === 1) {
+ return isSCMRepository(element) && selectedRepositories[0] !== element;
+ }
+ return isSCMRepository(element);
+ }
}
export class ScmRepositoriesView extends Disposable.Disposable implements IView {
@@ -62,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,
@@ -76,6 +88,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();
@@ -108,7 +121,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) => {
@@ -139,6 +152,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);
diff --git a/src/scm/scmRepositoryRenderer.ts b/src/scm/scmRepositoryRenderer.ts
index accaf7d..9f96b62 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();
}
}
@@ -143,6 +144,7 @@ export interface RepositoryTemplate {
readonly icon: HTMLElement;
readonly label: HTMLElement;
readonly action: RepositoryAction;
+ readonly elementDisposables: DisposableStore;
readonly templateDisposables: DisposableStore;
}
@@ -169,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();
}
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/style.scss b/src/scm/style.scss
index a9f65d5..a5514ce 100644
--- a/src/scm/style.scss
+++ b/src/scm/style.scss
@@ -369,7 +369,9 @@
}
.icon.loading,
- .icon.sync {
+ .icon.sync,
+ .icon.vscode-codicons_sync,
+ .icon.vscode-codicons_loading {
&.spin {
animation: spin 1s linear infinite;
}
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 {
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 {
]