diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index b793b65..ec54cd7 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -113,5 +113,6 @@ "remoteBranchDeleted": "Remote branch '{0}/{1}' deleted", "remoteAdded": "Remote '{0}' added", "remoteRemoved": "Remote '{0}' removed", + "openRemoteRepositoryFailed": "Unable to open remote repository from URL: {0}", "worktreeRemoved": "Worktree removed" } diff --git a/l10n/bundle.l10n.ko.json b/l10n/bundle.l10n.ko.json index c5dabd1..4cc7226 100644 --- a/l10n/bundle.l10n.ko.json +++ b/l10n/bundle.l10n.ko.json @@ -113,5 +113,6 @@ "remoteBranchDeleted": "리모트 브랜치 '{0}/{1}'이(가) 삭제되었습니다", "remoteAdded": "리모트 '{0}'이(가) 추가되었습니다", "remoteRemoved": "리모트 '{0}'이(가) 제거되었습니다", + "openRemoteRepositoryFailed": "URL에서 원격 저장소를 열 수 없습니다: {0}", "worktreeRemoved": "Worktree가 제거되었습니다" } diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index 30b988d..6293102 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -113,5 +113,6 @@ "remoteBranchDeleted": "远程分支 '{0}/{1}' 已删除", "remoteAdded": "远程 '{0}' 已添加", "remoteRemoved": "远程 '{0}' 已移除", + "openRemoteRepositoryFailed": "无法从 URL 打开远程仓库:{0}", "worktreeRemoved": "工作树已移除" } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 518fdee..2cada43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "git-graph-plus", - "version": "0.3.12", + "version": "0.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git-graph-plus", - "version": "0.3.12", + "version": "0.7.3", "license": "Apache-2.0", "dependencies": { "@vscode/codicons": "^0.0.45" @@ -1417,7 +1417,6 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -1567,7 +1566,6 @@ "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1985,7 +1983,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3439,7 +3436,6 @@ "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -5581,7 +5577,6 @@ "integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -5994,7 +5989,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6244,7 +6238,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -6361,7 +6354,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6395,7 +6387,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/src/git/__tests__/commit-link-rules.test.ts b/src/git/__tests__/commit-link-rules.test.ts index 2d6eac5..d913be3 100644 --- a/src/git/__tests__/commit-link-rules.test.ts +++ b/src/git/__tests__/commit-link-rules.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { parseRemoteHost, + remoteUrlToRepositoryWebUrl, buildBuiltinRules, resolveCommitLinkRules, type LinkRule, @@ -33,6 +34,25 @@ describe('parseRemoteHost', () => { }); }); +describe('remoteUrlToRepositoryWebUrl', () => { + it('converts ssh shorthand to an https repository URL', () => { + expect(remoteUrlToRepositoryWebUrl('git@github.com:owner/repo.git')).toBe('https://github.com/owner/repo'); + }); + + it('converts https remote URLs and strips the trailing .git suffix', () => { + expect(remoteUrlToRepositoryWebUrl('https://github.com/owner/repo.git')).toBe('https://github.com/owner/repo'); + }); + + it('keeps nested owner/group paths', () => { + expect(remoteUrlToRepositoryWebUrl('ssh://git@gitlab.com/group/subgroup/repo.git')).toBe('https://gitlab.com/group/subgroup/repo'); + }); + + it('returns null for an unparseable remote URL', () => { + expect(remoteUrlToRepositoryWebUrl('not a url')).toBeNull(); + expect(remoteUrlToRepositoryWebUrl(null)).toBeNull(); + }); +}); + describe('buildBuiltinRules', () => { it('builds github issue rule plus the always-on MR rule', () => { expect(buildBuiltinRules('git@github.com:owner/repo.git')).toEqual([ diff --git a/src/git/__tests__/git-error-formatter.test.ts b/src/git/__tests__/git-error-formatter.test.ts index 9a7daec..a7e850c 100644 --- a/src/git/__tests__/git-error-formatter.test.ts +++ b/src/git/__tests__/git-error-formatter.test.ts @@ -28,6 +28,14 @@ describe('formatGitError', () => { ].join('\n'); expect(formatGitError(stderr)).toBe("failed to push some refs to 'origin'"); }); + + it('drops git-flow default-branch notice when a real error follows', () => { + const stderr = [ + 'Using default branch names.', + 'fatal: Not a gitflow-enabled repo yet. Please run "git flow init" first.', + ].join('\n'); + expect(formatGitError(stderr)).toBe('Not a gitflow-enabled repo yet. Please run "git flow init" first.'); + }); }); describe('file list after error', () => { diff --git a/src/git/__tests__/git-service.extra.test.ts b/src/git/__tests__/git-service.extra.test.ts index 3b0ee02..9ebc530 100644 --- a/src/git/__tests__/git-service.extra.test.ts +++ b/src/git/__tests__/git-service.extra.test.ts @@ -1,10 +1,15 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { GitService, GitError } from '../git-service'; +import type { BranchInfo } from '../types'; function mockExec(service: GitService, fn: (args: string[]) => Promise) { (service as unknown as { exec: (args: string[]) => Promise }).exec = fn; } +function branch(name: string, over: Partial = {}): BranchInfo { + return { name, current: false, ahead: 0, behind: 0, hash: 'abc1234', ...over }; +} + describe('GitService — rootPath', () => { it('returns the path passed to the constructor', () => { const s = new GitService('/some/path'); @@ -183,38 +188,153 @@ describe('GitService — LFS', () => { describe('GitService — git-flow shortcuts', () => { let service: GitService; - beforeEach(() => { service = new GitService('/tmp/repo'); }); + beforeEach(() => { + service = new GitService('/tmp/repo'); + (service as unknown as { branches: () => Promise }).branches = async () => [ + branch('main', { upstream: 'origin/main' }), + branch('develop', { upstream: 'origin/develop' }), + branch('feature/login', { upstream: 'origin/feature/login' }), + branch('release/1.0', { upstream: 'origin/release/1.0' }), + branch('hotfix/1.0.1', { upstream: 'origin/hotfix/1.0.1' }), + ]; + }); + + const flowConfigResponses: Record = { + 'config --local --get gitflow.branch.master': 'main', + 'config --local --get gitflow.branch.develop': 'develop', + 'config --local --get gitflow.prefix.feature': 'feature/', + 'config --local --get gitflow.prefix.release': 'release/', + 'config --local --get gitflow.prefix.hotfix': 'hotfix/', + 'config --local --get gitflow.prefix.versiontag': 'v', + }; it('flowFeatureStart runs git flow feature start ', async () => { const calls: string[][] = []; - mockExec(service, async (args) => { calls.push(args); return 'ok'; }); + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'show-ref --verify --quiet refs/heads/feature/login') return ''; + return 'ok'; + }); await service.flowFeatureStart('login'); - expect(calls[0]).toEqual(['flow', 'feature', 'start', 'login']); + expect(calls).toContainEqual(['flow', 'feature', 'start', 'login']); }); it('flowFeatureFinish runs git flow feature finish ', async () => { const calls: string[][] = []; - mockExec(service, async (args) => { calls.push(args); return ''; }); + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + return ''; + }); await service.flowFeatureFinish('login'); - expect(calls[0]).toEqual(['flow', 'feature', 'finish', 'login']); + expect(calls).toContainEqual(['flow', 'feature', 'finish', 'login']); }); it('flowReleaseStart and flowReleaseFinish include -m message for finish', async () => { const calls: string[][] = []; - mockExec(service, async (args) => { calls.push(args); return ''; }); + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + return ''; + }); await service.flowReleaseStart('1.0'); await service.flowReleaseFinish('1.0'); - expect(calls[0]).toEqual(['flow', 'release', 'start', '1.0']); - expect(calls[1]).toEqual(['flow', 'release', 'finish', '-m', '1.0', '1.0']); + expect(calls).toContainEqual(['flow', 'release', 'start', '1.0']); + expect(calls).toContainEqual(['flow', 'release', 'finish', '-m', '1.0', '1.0']); }); it('flowHotfixStart and flowHotfixFinish behave the same way', async () => { const calls: string[][] = []; - mockExec(service, async (args) => { calls.push(args); return ''; }); + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + return ''; + }); await service.flowHotfixStart('1.0.1'); await service.flowHotfixFinish('1.0.1'); - expect(calls[0]).toEqual(['flow', 'hotfix', 'start', '1.0.1']); - expect(calls[1]).toEqual(['flow', 'hotfix', 'finish', '-m', '1.0.1', '1.0.1']); + expect(calls).toContainEqual(['flow', 'hotfix', 'start', '1.0.1']); + expect(calls).toContainEqual(['flow', 'hotfix', 'finish', '-m', '1.0.1', '1.0.1']); + }); + + it('flowFeatureStart rejects when git-flow reports success but the branch is missing', async () => { + mockExec(service, async (args) => { + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'flow feature start login') return 'ok'; + if (key === 'show-ref --verify --quiet refs/heads/feature/login') { + throw new GitError('', 1, args); + } + return ''; + }); + + await expect(service.flowFeatureStart('login')) + .rejects.toThrow("branch 'feature/login' was not created"); + }); + + it('flowHotfixStart pulls with rebase when the production branch is behind upstream', async () => { + (service as unknown as { branches: () => Promise }).branches = async () => [ + branch('main', { upstream: 'origin/main', behind: 2 }), + branch('develop', { current: true, upstream: 'origin/develop' }), + ]; + const calls: string[][] = []; + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'show-ref --verify --quiet refs/heads/hotfix/1.0.1') return ''; + return ''; + }); + + await service.flowHotfixStart('1.0.1'); + + expect(calls).toContainEqual(['checkout', 'main']); + expect(calls).toContainEqual(['pull', '--rebase']); + expect(calls).toContainEqual(['checkout', 'develop']); + expect(calls).toContainEqual(['flow', 'hotfix', 'start', '1.0.1']); + }); + + it('flowReleaseFinish pulls with rebase when a related branch is behind upstream', async () => { + (service as unknown as { branches: () => Promise }).branches = async () => [ + branch('main', { current: true, upstream: 'origin/main' }), + branch('develop', { upstream: 'origin/develop', behind: 1 }), + branch('release/1.0', { upstream: 'origin/release/1.0' }), + ]; + const calls: string[][] = []; + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + return ''; + }); + + await service.flowReleaseFinish('1.0'); + + expect(calls).toContainEqual(['checkout', 'develop']); + expect(calls).toContainEqual(['pull', '--rebase']); + expect(calls).toContainEqual(['checkout', 'main']); + expect(calls).toContainEqual(['flow', 'release', 'finish', '-m', '1.0', '1.0']); + }); + + it('flowHotfixStart stops before git-flow when pull --rebase fails', async () => { + (service as unknown as { branches: () => Promise }).branches = async () => [ + branch('main', { current: true, upstream: 'origin/main', behind: 1 }), + ]; + const calls: string[][] = []; + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'pull --rebase') throw new GitError('rebase conflict', 1, args); + return ''; + }); + + await expect(service.flowHotfixStart('1.0.1')).rejects.toThrow('rebase conflict'); + expect(calls).not.toContainEqual(['flow', 'hotfix', 'start', '1.0.1']); }); it('rejects flow names that start with "-" (CLI option injection)', async () => { @@ -251,12 +371,12 @@ describe('GitService — getFlowBranches', () => { it('groups branches by configured prefixes', async () => { const responses: Record = { - 'config --get gitflow.branch.master': 'main', - 'config --get gitflow.branch.develop': 'develop', - 'config --get gitflow.prefix.feature': 'feature/', - 'config --get gitflow.prefix.release': 'release/', - 'config --get gitflow.prefix.hotfix': 'hotfix/', - 'config --get gitflow.prefix.versiontag': 'v', + 'config --local --get gitflow.branch.master': 'main', + 'config --local --get gitflow.branch.develop': 'develop', + 'config --local --get gitflow.prefix.feature': 'feature/', + 'config --local --get gitflow.prefix.release': 'release/', + 'config --local --get gitflow.prefix.hotfix': 'hotfix/', + 'config --local --get gitflow.prefix.versiontag': 'v', 'branch --list': [ '* develop', ' feature/login', diff --git a/src/git/__tests__/git-service.test.ts b/src/git/__tests__/git-service.test.ts index 923a610..83e3bdd 100644 --- a/src/git/__tests__/git-service.test.ts +++ b/src/git/__tests__/git-service.test.ts @@ -1,11 +1,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GitService, GitError, binCommitTime, buildAmendCommandStr } from '../git-service'; +import type { BranchInfo } from '../types'; // Access private exec method via prototype for mocking function mockExec(service: GitService, fn: (args: string[]) => Promise) { (service as any).exec = fn; } +function branch(name: string, over: Partial = {}): BranchInfo { + return { name, current: false, ahead: 0, behind: 0, hash: 'abc1234', ...over }; +} + describe('GitService', () => { let service: GitService; @@ -968,17 +973,52 @@ describe('GitService', () => { }); it('isFlowInitialized returns false when gitflow.branch.master is unset', async () => { - mockExec(service, async () => { throw new GitError('', 1, ['config', '--get', 'gitflow.branch.master']); }); + mockExec(service, async () => { throw new GitError('', 1, ['config', '--local', '--get', 'gitflow.branch.master']); }); expect(await service.isFlowInitialized()).toBe(false); }); + it('isFlowInitialized returns false when saved flow config points to a missing develop branch', async () => { + const responses: Record = { + 'config --local --get gitflow.branch.master': 'main', + 'config --local --get gitflow.branch.develop': 'develop', + 'config --local --get gitflow.prefix.feature': 'feature/', + 'config --local --get gitflow.prefix.release': 'release/', + 'config --local --get gitflow.prefix.hotfix': 'hotfix/', + 'config --local --get gitflow.prefix.versiontag': 'v', + 'show-ref --verify --quiet refs/heads/main': '', + }; + mockExec(service, async (args) => { + const key = args.join(' '); + if (key in responses) return responses[key]; + throw new GitError('', 1, args); + }); + + expect(await service.isFlowInitialized()).toBe(false); + }); + + it('isFlowInitialized returns true only when config and required branches exist', async () => { + const responses: Record = { + 'config --local --get gitflow.branch.master': 'main', + 'config --local --get gitflow.branch.develop': 'develop', + 'config --local --get gitflow.prefix.feature': 'feature/', + 'config --local --get gitflow.prefix.release': 'release/', + 'config --local --get gitflow.prefix.hotfix': 'hotfix/', + 'config --local --get gitflow.prefix.versiontag': 'v', + 'show-ref --verify --quiet refs/heads/main': '', + 'show-ref --verify --quiet refs/heads/develop': '', + }; + mockExec(service, async (args) => responses[args.join(' ')] ?? ''); + + expect(await service.isFlowInitialized()).toBe(true); + }); + it('getFlowConfig returns null when any required key is missing', async () => { // First config key resolves, second rejects → whole thing nulls out. let calls = 0; mockExec(service, async () => { calls++; if (calls === 1) return 'main\n'; - throw new GitError('', 1, ['config', '--get', 'gitflow.branch.develop']); + throw new GitError('', 1, ['config', '--local', '--get', 'gitflow.branch.develop']); }); expect(await service.getFlowConfig()).toBeNull(); }); @@ -1293,32 +1333,83 @@ describe('GitService', () => { hotfixPrefix: 'hotfix/', versionTagPrefix: 'v', }; + const flowConfigResponses: Record = { + 'config --local --get gitflow.branch.master': 'main', + 'config --local --get gitflow.branch.develop': 'develop', + 'config --local --get gitflow.prefix.feature': 'feature/', + 'config --local --get gitflow.prefix.release': 'release/', + 'config --local --get gitflow.prefix.hotfix': 'hotfix/', + 'config --local --get gitflow.prefix.versiontag': 'v', + }; + beforeEach(() => { + (service as unknown as { branches: () => Promise }).branches = async () => [ + branch('main', { upstream: 'origin/main' }), + branch('develop', { upstream: 'origin/develop' }), + ]; + }); it('creates develop branch from production when develop is missing', async () => { const calls: string[][] = []; - let revParseCount = 0; + let developCreated = false; mockExec(service, async (args) => { calls.push(args); - if (args[0] === 'rev-parse') { - revParseCount++; - // 1st: verify production (succeeds). 2nd: verify develop (fails). - if (revParseCount === 1) return 'abc123'; + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'show-ref --verify --quiet refs/heads/main') return ''; + if (key === 'show-ref --verify --quiet refs/heads/develop') { + if (developCreated) return ''; throw new GitError("unknown revision 'develop'", 1, args); } + if (args[0] === 'branch') { + developCreated = true; + return ''; + } return ''; }); await service.flowInit(flowOpts); + expect(calls).toContainEqual(['flow', 'init', '-f', '-d']); const branchCreate = calls.find(c => c[0] === 'branch'); expect(branchCreate).toEqual(['branch', 'develop', 'main']); }); + it('clears stale flow branch config before forced git-flow init', async () => { + const calls: string[][] = []; + mockExec(service, async (args) => { + calls.push(args); + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'show-ref --verify --quiet refs/heads/main') return ''; + if (key === 'show-ref --verify --quiet refs/heads/develop') return ''; + return ''; + }); + + await service.flowInit(flowOpts); + + const unsetMaster = calls.findIndex(c => c.join(' ') === 'config --local --unset-all gitflow.branch.master'); + const unsetDevelop = calls.findIndex(c => c.join(' ') === 'config --local --unset-all gitflow.branch.develop'); + const setMaster = calls.findIndex(c => c.join(' ') === 'config --local --replace-all gitflow.branch.master main'); + const setDevelop = calls.findIndex(c => c.join(' ') === 'config --local --replace-all gitflow.branch.develop develop'); + const init = calls.findIndex(c => c.join(' ') === 'flow init -f -d'); + expect(unsetMaster).toBeGreaterThanOrEqual(0); + expect(unsetDevelop).toBeGreaterThanOrEqual(0); + expect(setMaster).toBeGreaterThanOrEqual(0); + expect(setDevelop).toBeGreaterThanOrEqual(0); + expect(init).toBeGreaterThan(unsetMaster); + expect(init).toBeGreaterThan(unsetDevelop); + expect(init).toBeGreaterThan(setMaster); + expect(init).toBeGreaterThan(setDevelop); + }); + it('skips develop creation when develop already exists', async () => { const calls: string[][] = []; mockExec(service, async (args) => { calls.push(args); - if (args[0] === 'rev-parse') return 'abc123'; + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'show-ref --verify --quiet refs/heads/main') return ''; + if (key === 'show-ref --verify --quiet refs/heads/develop') return ''; return ''; }); @@ -1330,13 +1421,40 @@ describe('GitService', () => { it('throws a helpful error when production branch is missing', async () => { mockExec(service, async (args) => { - if (args[0] === 'rev-parse') throw new GitError('not found', 1, args); + if (args.join(' ') === 'show-ref --verify --quiet refs/heads/main') { + throw new GitError('not found', 1, args); + } return ''; }); await expect(service.flowInit(flowOpts)) .rejects.toThrow("Branch 'main' does not exist"); }); + + it('pulls with rebase before init when production branch is behind upstream', async () => { + (service as unknown as { branches: () => Promise }).branches = async () => [ + branch('main', { current: true, upstream: 'origin/main', behind: 1 }), + ]; + const calls: string[][] = []; + mockExec(service, async (args) => { + calls.push(args); + if (args.join(' ') === 'show-ref --verify --quiet refs/heads/main') return ''; + const key = args.join(' '); + if (key in flowConfigResponses) return flowConfigResponses[key]; + if (key === 'show-ref --verify --quiet refs/heads/develop') return ''; + return ''; + }); + + await service.flowInit(flowOpts); + + expect(calls).toContainEqual(['pull', '--rebase']); + expect(calls).toContainEqual(['flow', 'init', '-f', '-d']); + }); + + it('rejects using the same production and develop branch', async () => { + await expect(service.flowInit({ ...flowOpts, developBranch: 'main' })) + .rejects.toThrow('production and develop branches must be different'); + }); }); }); diff --git a/src/git/commit-link-rules.ts b/src/git/commit-link-rules.ts index d61632c..c4cc5ec 100644 --- a/src/git/commit-link-rules.ts +++ b/src/git/commit-link-rules.ts @@ -28,6 +28,16 @@ export function parseRemoteHost(url: string): RemoteInfo | null { return null; } +/** + * Convert a git remote URL to the repository page that can be opened in a browser. + */ +export function remoteUrlToRepositoryWebUrl(remoteUrl: string | null): string | null { + if (!remoteUrl) return null; + const info = parseRemoteHost(remoteUrl); + if (!info) return null; + return `https://${info.host}/${info.owner}/${info.repo}`; +} + /** * Built-in auto-link rules derived from the repo's remote. * @@ -41,7 +51,8 @@ export function buildBuiltinRules(remoteUrl: string | null): LinkRule[] { if (!remoteUrl) return []; const info = parseRemoteHost(remoteUrl); if (!info) return []; - const base = `https://${info.host}/${info.owner}/${info.repo}`; + const base = remoteUrlToRepositoryWebUrl(remoteUrl); + if (!base) return []; const rules: LinkRule[] = [ // `!N` is GitLab-only — safe to link on any host. { pattern: '!(\\d+)', url: `${base}/-/merge_requests/$1` }, diff --git a/src/git/git-error-formatter.ts b/src/git/git-error-formatter.ts index 8dfbc61..1ad242a 100644 --- a/src/git/git-error-formatter.ts +++ b/src/git/git-error-formatter.ts @@ -27,7 +27,9 @@ function joinCapped(parts: string[]): string { export function formatGitError(stderr: string): string { // git pads `remote:` lines with trailing spaces; strip them so joined // messages stay clean. - const rawLines = stderr.split('\n').map(l => l.replace(/\s+$/, '')); + let rawLines = stderr.split('\n').map(l => l.replace(/\s+$/, '')); + const actionableLines = rawLines.filter(l => l.trim() !== 'Using default branch names.'); + if (actionableLines.length > 0) rawLines = actionableLines; if (rawLines.every(l => l.length === 0)) return stderr.trim(); // 1. Remote server error/fatal lines are most specific (e.g. GitHub rule diff --git a/src/git/git-parser.ts b/src/git/git-parser.ts index 9c7db9e..589fb04 100644 --- a/src/git/git-parser.ts +++ b/src/git/git-parser.ts @@ -229,7 +229,10 @@ export function parseDiff(raw: string, file?: string): DiffData[] { // Prefer the +++/--- header lines: they each carry the full path on their // own line, so they stay unambiguous even when the path contains spaces or // a literal " b/" substring that would mis-split the "diff --git" header. - let filePath = resolveDiffPathFromBody(lines) ?? ''; + const bodyPaths = resolveDiffPathsFromBody(lines); + let oldFile = bodyPaths.oldPath; + let newFile = bodyPaths.newPath; + let filePath = newFile ?? oldFile ?? ''; if (!filePath) { // Fallback for diffs without +++/--- lines (e.g. pure mode/rename headers): // parse "a/path b/path" or quoted paths like "\"a/path\" \"b/path\"". @@ -237,10 +240,20 @@ export function parseDiff(raw: string, file?: string): DiffData[] { const quotedMatch = header.match(/^"?a\/(.+?)"?\s+"?b\/(.+?)"?\s*$/); if (quotedMatch) { // Unescape git's quoted path encoding (e.g. \t, \n, octal \NNN) - filePath = unescapeGitPath(quotedMatch[2]); + oldFile = unescapeGitPath(quotedMatch[1]); + newFile = unescapeGitPath(quotedMatch[2]); + filePath = newFile; } else { const headerMatch = header.match(/a\/(.+?) b\/(.+)/); - filePath = headerMatch ? headerMatch[2] : file ?? 'unknown'; + if (headerMatch) { + oldFile = headerMatch[1]; + newFile = headerMatch[2]; + filePath = newFile; + } else { + filePath = file ?? 'unknown'; + oldFile = filePath; + newFile = filePath; + } } } @@ -249,7 +262,10 @@ export function parseDiff(raw: string, file?: string): DiffData[] { const isImage = /\.(png|jpg|jpeg|gif|bmp|svg|webp|ico)$/i.test(filePath); if (isBinary) { - results.push({ file: filePath, hunks: [], isBinary: true, isImage }); + const diff: DiffData = { file: filePath, hunks: [], isBinary: true, isImage }; + if (oldFile && oldFile !== filePath) diff.oldFile = oldFile; + if (newFile && newFile !== filePath) diff.newFile = newFile; + results.push(diff); continue; } @@ -316,7 +332,10 @@ export function parseDiff(raw: string, file?: string): DiffData[] { } } - results.push({ file: filePath, hunks, isBinary: false, isImage }); + const diff: DiffData = { file: filePath, hunks, isBinary: false, isImage }; + if (oldFile && oldFile !== filePath) diff.oldFile = oldFile; + if (newFile && newFile !== filePath) diff.newFile = newFile; + results.push(diff); } return results; @@ -414,11 +433,10 @@ export function parseLfsLocks(raw: string): Array<{ path: string; owner: string; }); } -/** Resolve a file path from the +++/--- header lines of a single file diff. - * Prefers the post-image (+++) path; falls back to the pre-image (---) path - * for deletions where +++ is /dev/null. Returns null when neither is usable - * (e.g. a rename/mode-only diff with no +++/--- lines). */ -function resolveDiffPathFromBody(lines: string[]): string | null { +/** Resolve pre/post image paths from the +++/--- header lines of a single file + * diff. /dev/null is omitted so additions/deletions naturally have only one + * populated side. */ +function resolveDiffPathsFromBody(lines: string[]): { oldPath?: string; newPath?: string } { let plusPath: string | null = null; let minusPath: string | null = null; for (let i = 1; i < lines.length; i++) { @@ -429,7 +447,10 @@ function resolveDiffPathFromBody(lines: string[]): string | null { if (line.startsWith('+++ ')) plusPath = stripDiffPathPrefix(line.slice(4)); else if (line.startsWith('--- ')) minusPath = stripDiffPathPrefix(line.slice(4)); } - return plusPath ?? minusPath; + return { + oldPath: minusPath ?? undefined, + newPath: plusPath ?? undefined, + }; } /** Strip the leading `a/` or `b/` from a +++/--- path, unescape git quoting, diff --git a/src/git/git-service.ts b/src/git/git-service.ts index 71e6576..1fb9b0c 100644 --- a/src/git/git-service.ts +++ b/src/git/git-service.ts @@ -1,6 +1,6 @@ import { spawn } from 'child_process'; import { existsSync } from 'fs'; -import { writeFile, unlink } from 'fs/promises'; +import { readFile, writeFile, unlink } from 'fs/promises'; import { join } from 'path'; import { randomUUID } from 'crypto'; import { bufferStream, BufferOverflowError } from '../utils/buffer-stream'; @@ -17,6 +17,28 @@ import { parseLog, parseBranches, parseTags, parseRemotes, parseStashList, parse import { buildReversePatch } from './patch-builder'; import type { Commit, BranchInfo, TagInfo, RemoteInfo, StashEntry, LogOptions, DiffData, WorktreeInfo, CommitSignature } from './types'; +type GitFlowConfig = { + productionBranch: string; + developBranch: string; + featurePrefix: string; + releasePrefix: string; + hotfixPrefix: string; + versionTagPrefix: string; +}; + +type FileContentSource = + | { kind: 'empty' } + | { kind: 'ref'; ref: string } + | { kind: 'index' } + | { kind: 'working' }; + +type CommitFileDiffResult = { + raw: string; + parsed: DiffData[]; + oldSource: FileContentSource; + newSource: FileContentSource; +}; + export class GitError extends Error { constructor( public stderr: string, @@ -764,7 +786,14 @@ export class GitService { } const raw = await this.exec(args); - return parseDiff(raw, options?.file); + const parsed = parseDiff(raw, options?.file); + if (options?.ref1 && options?.ref2) { + return this.attachDiffContent(parsed, { kind: 'ref', ref: options.ref1 }, { kind: 'ref', ref: options.ref2 }); + } + if (options?.ref1) { + return this.attachDiffContent(parsed, { kind: 'ref', ref: options.ref1 }, { kind: 'working' }); + } + return this.attachDiffContent(parsed, { kind: 'index' }, { kind: 'working' }); } // --- Branch Management --- @@ -804,17 +833,59 @@ export class GitService { this.assertSafePath(file, 'diff'); if (staged) { const raw = await this.exec(['diff', '--no-color', '--cached', '--', file]).catch(() => ''); - return parseDiff(raw, file)[0] ?? null; + const parsed = await this.attachDiffContent(parseDiff(raw, file), { kind: 'ref', ref: 'HEAD' }, { kind: 'index' }); + return parsed[0] ?? null; } const isTracked = await this.exec(['ls-files', '--error-unmatch', '--', file]).then(() => true).catch(() => false); if (!isTracked) { // --no-index exits with code 1 when differences found (normal); stdout has the diff const raw = await this.exec(['diff', '--no-color', '--no-index', '--', '/dev/null', file]) .catch(err => (err instanceof GitError && err.exitCode === 1) ? err.stdout : ''); - return parseDiff(raw, file)[0] ?? null; + const parsed = await this.attachDiffContent(parseDiff(raw, file), { kind: 'empty' }, { kind: 'working' }); + return parsed[0] ?? null; } const raw = await this.exec(['diff', '--no-color', '--', file]).catch(() => ''); - return parseDiff(raw, file)[0] ?? null; + const parsed = await this.attachDiffContent(parseDiff(raw, file), { kind: 'index' }, { kind: 'working' }); + return parsed[0] ?? null; + } + + private async readFileContent(source: FileContentSource, file: string): Promise { + this.assertSafePath(file, 'read file content'); + try { + if (source.kind === 'empty') return ''; + if (source.kind === 'working') { + return await readFile(join(this.repoPath, file), 'utf8'); + } + if (source.kind === 'index') { + return await this.exec(['show', `:${file}`], { silent: true }); + } + this.assertSafeRef(source.ref, 'read file content'); + return await this.exec(['show', `${source.ref}:${file}`], { silent: true }); + } catch { + // Added/deleted/renamed files may be absent on one side. Treat that side as + // empty so full-file tokenization can still map the present side accurately. + return ''; + } + } + + private async attachDiffContent( + diffs: DiffData[], + oldSource: FileContentSource, + newSource: FileContentSource, + ): Promise { + return Promise.all(diffs.map(async diff => { + if (diff.isBinary) return diff; + const oldPath = diff.oldFile ?? diff.file; + const newPath = diff.newFile ?? diff.file; + const [oldText, newText] = await Promise.all([ + this.readFileContent(oldSource, oldPath), + this.readFileContent(newSource, newPath), + ]); + return { + ...diff, + content: { oldText, newText, oldPath, newPath }, + }; + })); } private parseNameStatus(raw: string): Array<{ path: string; status: string; oldPath?: string }> { @@ -1110,7 +1181,7 @@ export class GitService { this.assertSafeRef(ref1, 'diff'); this.assertSafeRef(ref2, 'diff'); const raw = await this.exec(['diff', '--no-color', ref1, ref2]); - return parseDiff(raw); + return this.attachDiffContent(parseDiff(raw), { kind: 'ref', ref: ref1 }, { kind: 'ref', ref: ref2 }); } async diffFiles(ref1: string, ref2?: string): Promise> { @@ -1238,17 +1309,20 @@ export class GitService { } if (file) { - return (await this.commitFileDiff(hash, file)).parsed; + const result = await this.commitFileDiff(hash, file); + return this.attachDiffContent(result.parsed, result.oldSource, result.newSource); } // Overview (no specific file). const parents = await this.commitParents(hash); if (parents.length === 0) { // Root commit: diff against empty tree. - return parseDiff(await this.exec(['show', '--no-color', '--format=', hash])); + const parsed = parseDiff(await this.exec(['show', '--no-color', '--format=', hash])); + return this.attachDiffContent(parsed, { kind: 'empty' }, { kind: 'ref', ref: hash }); } // Single-parent commit, or merge overview (first-parent diff). - return parseDiff(await this.exec(['diff', '--no-color', `${hash}^..${hash}`])); + const parsed = parseDiff(await this.exec(['diff', '--no-color', `${hash}^..${hash}`])); + return this.attachDiffContent(parsed, { kind: 'ref', ref: parents[0] }, { kind: 'ref', ref: hash }); } /** @@ -1258,7 +1332,7 @@ export class GitService { * patch we reverse ({@link reverseCommitChanges}) can never pick different * parents — see the merge-commit case below. */ - private async commitFileDiff(hash: string, file: string): Promise<{ raw: string; parsed: DiffData[] }> { + private async commitFileDiff(hash: string, file: string): Promise { this.assertSafeRef(hash, 'diff'); this.assertSafePath(file, 'diff'); const parents = await this.commitParents(hash); @@ -1266,7 +1340,7 @@ export class GitService { if (parents.length === 0) { // Root commit: diff against the empty tree. const raw = await this.exec(['show', '--no-color', '--format=', hash, '--', file]); - return { raw, parsed: parseDiff(raw) }; + return { raw, parsed: parseDiff(raw), oldSource: { kind: 'empty' }, newSource: { kind: 'ref', ref: hash } }; } if (parents.length > 1) { @@ -1280,14 +1354,14 @@ export class GitService { const raw = await this.exec(['diff', '--no-color', `${parent}..${hash}`, '--', file]); const parsed = parseDiff(raw); if (parsed.length > 0 && parsed[0].hunks.length > 0) { - return { raw, parsed }; + return { raw, parsed, oldSource: { kind: 'ref', ref: parent }, newSource: { kind: 'ref', ref: hash } }; } } - return { raw: '', parsed: [] }; + return { raw: '', parsed: [], oldSource: { kind: 'ref', ref: parents[0] }, newSource: { kind: 'ref', ref: hash } }; } const raw = await this.exec(['diff', '--no-color', `${hash}^..${hash}`, '--', file]); - return { raw, parsed: parseDiff(raw) }; + return { raw, parsed: parseDiff(raw), oldSource: { kind: 'ref', ref: parents[0] }, newSource: { kind: 'ref', ref: hash } }; } /** @@ -1331,7 +1405,11 @@ export class GitService { this.assertSafeRef(parents[0], 'diff'); const trackedArgs = ['diff', '--no-color', `${parents[0]}..${hash}`]; if (file) trackedArgs.push('--', file); - const tracked = parseDiff(await this.exec(trackedArgs)); + const tracked = await this.attachDiffContent( + parseDiff(await this.exec(trackedArgs)), + { kind: 'ref', ref: parents[0] }, + { kind: 'ref', ref: hash }, + ); // A requested file that lives in the tracked diff needs no untracked lookup. if (file && tracked.length > 0) return tracked; @@ -1341,7 +1419,11 @@ export class GitService { this.assertSafeRef(parents[2], 'diff'); const untrackedArgs = ['show', '--no-color', '--format=', parents[2]]; if (file) untrackedArgs.push('--', file); - untracked = parseDiff(await this.exec(untrackedArgs)); + untracked = await this.attachDiffContent( + parseDiff(await this.exec(untrackedArgs)), + { kind: 'empty' }, + { kind: 'ref', ref: parents[2] }, + ); } if (file) return untracked; @@ -2180,11 +2262,86 @@ export class GitService { async diffCommitToWorking(hash: string): Promise { this.assertSafeRef(hash, 'diff'); const raw = await this.exec(['diff', hash]); - return parseDiff(raw); + return this.attachDiffContent(parseDiff(raw), { kind: 'ref', ref: hash }, { kind: 'working' }); } // --- Git Flow --- + private async localBranchExists(name: string): Promise { + this.assertSafeRef(name, 'branch exists'); + try { + await this.exec(['show-ref', '--verify', '--quiet', `refs/heads/${name}`], { silent: true }); + return true; + } catch { + return false; + } + } + + private async assertLocalBranchExists(name: string, operation: string): Promise { + if (await this.localBranchExists(name)) return; + throw new GitError(`${operation} reported success, but branch '${name}' was not created.`, 1, []); + } + + private async pullRebaseBranchesBehindUpstream(branchNames: string[]): Promise { + const names = Array.from(new Set(branchNames.filter(Boolean))); + if (names.length === 0) return; + const nameSet = new Set(names); + const branches = await this.branches(); + const behindBranches = branches + .filter(branch => !branch.remote && nameSet.has(branch.name) && !!branch.upstream && !branch.upstreamGone && branch.behind > 0); + if (behindBranches.length === 0) return; + + const currentBranch = branches.find(branch => branch.current && !branch.remote)?.name; + const originalRef = currentBranch + ?? await this.exec(['rev-parse', '--verify', 'HEAD'], { silent: true }).then(s => s.trim()).catch(() => undefined); + let checkedOutBranch = currentBranch; + + for (const branch of behindBranches) { + if (checkedOutBranch !== branch.name) { + await this.checkout(branch.name); + checkedOutBranch = branch.name; + } + await this.pull(undefined, undefined, { rebase: true }); + } + + if (originalRef && checkedOutBranch !== originalRef) { + await this.checkout(originalRef); + } + } + + private async requireFlowConfig(): Promise { + const config = await this.getFlowConfig(); + if (!config) { + throw new GitError('Git Flow is not initialized for this repository.', 1, ['flow']); + } + return config; + } + + private flowConfigMatches(config: GitFlowConfig, options: GitFlowConfig): boolean { + return config.productionBranch === options.productionBranch + && config.developBranch === options.developBranch + && config.featurePrefix === options.featurePrefix + && config.releasePrefix === options.releasePrefix + && config.hotfixPrefix === options.hotfixPrefix + && config.versionTagPrefix === options.versionTagPrefix; + } + + private async clearFlowBranchConfig(): Promise { + await Promise.all([ + this.exec(['config', '--local', '--unset-all', 'gitflow.branch.master'], { silent: true }).catch(() => undefined), + this.exec(['config', '--local', '--unset-all', 'gitflow.branch.develop'], { silent: true }).catch(() => undefined), + ]); + } + + private async writeFlowConfig(options: GitFlowConfig): Promise { + await this.exec(['config', '--local', '--replace-all', 'gitflow.branch.master', options.productionBranch]); + await this.exec(['config', '--local', '--replace-all', 'gitflow.branch.develop', options.developBranch]); + await this.exec(['config', '--local', '--replace-all', 'gitflow.prefix.feature', options.featurePrefix]); + await this.exec(['config', '--local', '--replace-all', 'gitflow.prefix.release', options.releasePrefix]); + await this.exec(['config', '--local', '--replace-all', 'gitflow.prefix.hotfix', options.hotfixPrefix]); + await this.exec(['config', '--local', '--replace-all', 'gitflow.prefix.versiontag', options.versionTagPrefix]); + } + async flowInit(options: { productionBranch: string; developBranch: string; @@ -2193,82 +2350,107 @@ export class GitService { hotfixPrefix: string; versionTagPrefix: string; }): Promise { - // production 브랜치 존재 여부 검증 - try { - await this.exec(['rev-parse', '--verify', options.productionBranch]); - } catch { + if (options.productionBranch === options.developBranch) { + throw new GitError('Git Flow production and develop branches must be different.', 1, ['flow', 'init']); + } + + if (!(await this.localBranchExists(options.productionBranch))) { throw new GitError( `Branch '${options.productionBranch}' does not exist. Create the production branch first or ensure at least one commit exists.`, 1, ['flow', 'init'] ); } + await this.pullRebaseBranchesBehindUpstream([options.productionBranch]); - // git flow init -d로 기본 초기화 후 커스텀 설정 덮어쓰기 - await this.exec(['flow', 'init', '-d']); - await this.exec(['config', 'gitflow.branch.master', options.productionBranch]); - await this.exec(['config', 'gitflow.branch.develop', options.developBranch]); - await this.exec(['config', 'gitflow.prefix.feature', options.featurePrefix]); - await this.exec(['config', 'gitflow.prefix.release', options.releasePrefix]); - await this.exec(['config', 'gitflow.prefix.hotfix', options.hotfixPrefix]); - await this.exec(['config', 'gitflow.prefix.versiontag', options.versionTagPrefix]); + await this.clearFlowBranchConfig(); + await this.exec(['config', '--local', '--replace-all', 'gitflow.branch.master', options.productionBranch]); + await this.exec(['config', '--local', '--replace-all', 'gitflow.branch.develop', options.developBranch]); + await this.exec(['flow', 'init', '-f', '-d']); - // develop 브랜치가 없으면 생성 - try { - await this.exec(['rev-parse', '--verify', options.developBranch]); - } catch { + if (!(await this.localBranchExists(options.developBranch))) { await this.exec(['branch', options.developBranch, options.productionBranch]); } + await this.assertLocalBranchExists(options.developBranch, 'Git Flow initialization'); + + await this.writeFlowConfig(options); + + const persistedConfig = await this.getFlowConfig(); + if (!persistedConfig || !this.flowConfigMatches(persistedConfig, options)) { + throw new GitError('Git Flow initialization did not persist the requested configuration.', 1, ['flow', 'init']); + } return 'Git Flow initialized'; } async flowFeatureStart(name: string): Promise { this.assertSafeRef(name, 'flow feature start'); - return this.exec(['flow', 'feature', 'start', name]); + const config = await this.requireFlowConfig(); + await this.pullRebaseBranchesBehindUpstream([config.developBranch]); + const result = await this.exec(['flow', 'feature', 'start', name]); + await this.assertLocalBranchExists(`${config.featurePrefix}${name}`, 'Git Flow feature start'); + return result; } async flowFeatureFinish(name: string): Promise { this.assertSafeRef(name, 'flow feature finish'); + const config = await this.requireFlowConfig(); + await this.pullRebaseBranchesBehindUpstream([ + config.developBranch, + `${config.featurePrefix}${name}`, + ]); return this.exec(['flow', 'feature', 'finish', name]); } async flowReleaseStart(version: string): Promise { this.assertSafeRef(version, 'flow release start'); - return this.exec(['flow', 'release', 'start', version]); + const config = await this.requireFlowConfig(); + await this.pullRebaseBranchesBehindUpstream([config.developBranch]); + const result = await this.exec(['flow', 'release', 'start', version]); + await this.assertLocalBranchExists(`${config.releasePrefix}${version}`, 'Git Flow release start'); + return result; } async flowReleaseFinish(version: string): Promise { this.assertSafeRef(version, 'flow release finish'); + const config = await this.requireFlowConfig(); + await this.pullRebaseBranchesBehindUpstream([ + config.productionBranch, + config.developBranch, + `${config.releasePrefix}${version}`, + ]); return this.exec(['flow', 'release', 'finish', '-m', version, version]); } async flowHotfixStart(version: string): Promise { this.assertSafeRef(version, 'flow hotfix start'); - return this.exec(['flow', 'hotfix', 'start', version]); + const config = await this.requireFlowConfig(); + await this.pullRebaseBranchesBehindUpstream([config.productionBranch]); + const result = await this.exec(['flow', 'hotfix', 'start', version]); + await this.assertLocalBranchExists(`${config.hotfixPrefix}${version}`, 'Git Flow hotfix start'); + return result; } async flowHotfixFinish(version: string): Promise { this.assertSafeRef(version, 'flow hotfix finish'); + const config = await this.requireFlowConfig(); + await this.pullRebaseBranchesBehindUpstream([ + config.productionBranch, + config.developBranch, + `${config.hotfixPrefix}${version}`, + ]); return this.exec(['flow', 'hotfix', 'finish', '-m', version, version]); } - async getFlowConfig(): Promise<{ - productionBranch: string; - developBranch: string; - featurePrefix: string; - releasePrefix: string; - hotfixPrefix: string; - versionTagPrefix: string; - } | null> { + async getFlowConfig(): Promise { try { const [production, develop, feature, release, hotfix, versionTag] = await Promise.all([ - this.exec(['config', '--get', 'gitflow.branch.master']).then(s => s.trim()), - this.exec(['config', '--get', 'gitflow.branch.develop']).then(s => s.trim()), - this.exec(['config', '--get', 'gitflow.prefix.feature']).then(s => s.trim()), - this.exec(['config', '--get', 'gitflow.prefix.release']).then(s => s.trim()), - this.exec(['config', '--get', 'gitflow.prefix.hotfix']).then(s => s.trim()), - this.exec(['config', '--get', 'gitflow.prefix.versiontag']).then(s => s.trim()).catch(() => ''), + this.exec(['config', '--local', '--get', 'gitflow.branch.master']).then(s => s.trim()), + this.exec(['config', '--local', '--get', 'gitflow.branch.develop']).then(s => s.trim()), + this.exec(['config', '--local', '--get', 'gitflow.prefix.feature']).then(s => s.trim()), + this.exec(['config', '--local', '--get', 'gitflow.prefix.release']).then(s => s.trim()), + this.exec(['config', '--local', '--get', 'gitflow.prefix.hotfix']).then(s => s.trim()), + this.exec(['config', '--local', '--get', 'gitflow.prefix.versiontag']).then(s => s.trim()).catch(() => ''), ]); return { productionBranch: production, @@ -2278,7 +2460,11 @@ export class GitService { hotfixPrefix: hotfix, versionTagPrefix: versionTag, }; - } catch (err) { console.warn('Git Graph+: failed to get flow config:', err instanceof Error ? err.message : err); return null; } + } catch (err) { + if (err instanceof GitError && err.exitCode === 1) return null; + console.warn('Git Graph+: failed to get flow config:', err instanceof Error ? err.message : err); + return null; + } } async getFlowBranches(): Promise<{ features: string[]; releases: string[]; hotfixes: string[] }> { @@ -2452,9 +2638,18 @@ export class GitService { async isFlowInitialized(): Promise { try { - await this.exec(['config', '--get', 'gitflow.branch.master']); - return true; - } catch (err) { console.warn('Git Graph+: flow init check failed:', err instanceof Error ? err.message : err); return false; } + const config = await this.getFlowConfig(); + if (!config) return false; + const [productionExists, developExists] = await Promise.all([ + this.localBranchExists(config.productionBranch), + this.localBranchExists(config.developBranch), + ]); + return productionExists && developExists; + } catch (err) { + if (err instanceof GitError && err.exitCode === 1) return false; + console.warn('Git Graph+: flow init check failed:', err instanceof Error ? err.message : err); + return false; + } } } diff --git a/src/git/types.ts b/src/git/types.ts index feed7ab..a12450d 100644 --- a/src/git/types.ts +++ b/src/git/types.ts @@ -120,11 +120,21 @@ export interface StashEntry { export interface DiffData { file: string; + oldFile?: string; + newFile?: string; + content?: DiffFileContent; hunks: DiffHunk[]; isBinary: boolean; isImage: boolean; } +export interface DiffFileContent { + oldText: string; + newText: string; + oldPath: string; + newPath: string; +} + export interface DiffHunk { header: string; oldStart: number; diff --git a/src/panels/MainPanel.ts b/src/panels/MainPanel.ts index 5869905..3c5d40a 100644 --- a/src/panels/MainPanel.ts +++ b/src/panels/MainPanel.ts @@ -16,7 +16,7 @@ import { AvatarCache } from '../services/avatar-cache'; import { resolveGitDirs, shouldRefreshGraph } from '../services/file-watcher-helpers'; import { RepoDiscoveryService, RepoInfo } from '../services/repo-discovery'; import type { WebviewMessage, ModalDefaults } from '../utils/message-bus'; -import { resolveCommitLinkRules, type LinkRule } from '../git/commit-link-rules'; +import { remoteUrlToRepositoryWebUrl, resolveCommitLinkRules, type LinkRule } from '../git/commit-link-rules'; import { resolveRepoRelativePath as resolveRepoRelativePathUtil, assertSafeArgPath as assertSafeArgPathUtil, @@ -49,6 +49,9 @@ export class MainPanel { private isFirstGetLog = true; private logSequence = 0; private searchSequence = 0; + private reflogSequence = 0; + private statsSequence = 0; + private repoGeneration = 0; // Two independent guards: selecting a commit (loads its file list) and // selecting a file (loads that file's diff) are different axes, so a file // request must not invalidate a pending commit-files request and vice versa. @@ -144,6 +147,34 @@ export class MainPanel { } as ModalDefaults; } + /** + * Use VS Code's built-in Git extension as an authoritative source for repos + * already discovered by the editor. The filesystem scanner below is still + * useful as a fallback/supplement, but it intentionally has depth/ignore + * limits and can miss repos that VS Code's SCM view already knows about. + */ + private async getVsCodeGitRepoPaths(): Promise { + try { + const gitExtension = vscode.extensions.getExtension('vscode.git'); + if (!gitExtension) return []; + + const extensionExports = gitExtension.isActive + ? gitExtension.exports + : await gitExtension.activate(); + const api = (extensionExports as { + getAPI?: (version: number) => { + repositories?: Array<{ rootUri?: vscode.Uri }>; + }; + } | undefined)?.getAPI?.(1); + + return (api?.repositories ?? []) + .map(repo => repo.rootUri?.fsPath) + .filter((repoPath): repoPath is string => typeof repoPath === 'string' && repoPath.length > 0); + } catch { + return []; + } + } + // Width (px) of the colored branch-badge bar, mapped from the user setting. private readBadgeBarWidth(): number { const level = vscode.workspace @@ -332,16 +363,25 @@ export class MainPanel { /** * Point the panel at a different repo: rebuild the GitService, reset - * repo-specific state, swap the file watcher, and notify the sidebar. Callers - * still own posting the repo list and triggering the refresh. + * repo-specific state, swap the file watcher, notify repo-bound webview + * widgets, and notify the sidebar. Callers still own posting the repo list + * and triggering the refresh. * * NOTE: the sequence guards (logSequence/searchSequence/*Sequence) are - * intentionally NOT reset. They stay monotonic for the panel's lifetime so a - * request still in flight against the old repo can never share a seq with a - * fresh request against the new one — resetting reuses numbers and lets a - * stale response paint the previous repo's graph over the current one. + * intentionally NOT reset. They stay monotonic for the panel's lifetime and + * are advanced on repo switches so any request still in flight against the old + * repo cannot paint stale data over the current repo. */ private swapRepo(newPath: string): void { + this.repoGeneration++; + this.logSequence++; + this.searchSequence++; + this.reflogSequence++; + this.statsSequence++; + this.commitFilesSequence.issue(); + this.fileDiffSequence.issue(); + this.multiCommitSectionsSequence.issue(); + this.repoPath = newPath; this.gitService = this.createGitService(newPath); @@ -359,6 +399,7 @@ export class MainPanel { this.disposables.push(this.fileWatcher); MainPanel.onRepoChange?.(newPath); + this.post({ type: 'repoChanged', payload: { what: 'repo' } }); } public async switchRepo(newPath: string): Promise { @@ -770,6 +811,28 @@ export class MainPanel { } break; } + case 'openRemoteRepository': { + const remoteName = message.payload?.remote?.trim(); + let remoteUrl: string | undefined; + + if (remoteName) { + remoteUrl = await this.gitService.getRemoteUrl(remoteName); + } else { + const remotes = await this.gitService.remotes(); + const remote = remotes.find(r => r.name === 'origin') ?? remotes[0]; + if (!remote) { + throw new Error(vscode.l10n.t('noRemotes')); + } + remoteUrl = remote.fetchUrl || remote.pushUrl || await this.gitService.getRemoteUrl(remote.name); + } + + const url = remoteUrlToRepositoryWebUrl(remoteUrl); + if (!url) { + throw new Error(vscode.l10n.t('openRemoteRepositoryFailed', remoteUrl)); + } + await vscode.env.openExternal(vscode.Uri.parse(url)); + break; + } case 'openExtensionSettings': { // Opens VS Code Settings pre-filtered to this extension's settings. await vscode.commands.executeCommand('workbench.action.openSettings', '@ext:the0807.git-graph-plus'); @@ -1200,7 +1263,9 @@ export class MainPanel { break; } case 'getReflog': { + const seq = ++this.reflogSequence; const result = await this.gitService.getReflog(message.payload?.limit ?? 200, message.payload?.ref ?? 'HEAD'); + if (seq !== this.reflogSequence) break; this.post({ type: 'reflogData', payload: result }); break; } @@ -1238,10 +1303,12 @@ export class MainPanel { } // --- Statistics --- case 'getStats': { + const seq = ++this.statsSequence; const [byAuthor, byWeekdayHour] = await Promise.all([ this.gitService.statsCommitsByAuthor(), this.gitService.statsCommitsByWeekdayHour(), ]); + if (seq !== this.statsSequence) break; this.post({ type: 'statsData', payload: { byAuthor, byWeekdayHour }, @@ -1773,6 +1840,7 @@ export class MainPanel { } this.refreshing = true; this.refreshQueued = false; + const repoGeneration = this.repoGeneration; // Watcher events caused by the same git operation that triggered this refresh // would arrive ~immediately after; absorb them so they don't fire a second pass. this.fileWatcher.suppress(); @@ -1808,6 +1876,7 @@ export class MainPanel { this.gitService.log(logArgs), this.gitService.branches(), ]); + if (repoGeneration !== this.repoGeneration) return; this.post({ type: 'logData', payload: buildLogData(allFetched, branches) }); } else { const [allFetched, branches, tags, remotes, stashes, worktrees] = await Promise.all([ @@ -1818,6 +1887,7 @@ export class MainPanel { this.gitService.stashList(), this.gitService.worktreeList(), ]); + if (repoGeneration !== this.repoGeneration) return; // Send as single combined message to ensure atomic update this.post({ type: 'fullRefresh', @@ -1830,7 +1900,7 @@ export class MainPanel { } } catch (err) { console.warn('Git Graph+: refresh failed:', err instanceof Error ? err.message : err); - if (err instanceof GitError && /not a git repository/.test(err.stderr)) { + if (repoGeneration === this.repoGeneration && err instanceof GitError && /not a git repository/.test(err.stderr)) { try { this.post({ type: 'notGitRepo' }); } catch { /* panel disposed */ } } } finally { @@ -1845,12 +1915,23 @@ export class MainPanel { } private repoListPending: Promise | null = null; + private repoListForceQueued = false; public sendRepoList(forceDiscovery = false): Promise { // Deduplicate concurrent calls - if (!this.repoListPending) { - this.repoListPending = this.doSendRepoList(forceDiscovery).finally(() => { this.repoListPending = null; }); + if (this.repoListPending) { + if (!forceDiscovery) { + return this.repoListPending; + } + this.repoListForceQueued = true; + return this.repoListPending.then(() => { + if (!this.repoListForceQueued) return; + this.repoListForceQueued = false; + return this.sendRepoList(true); + }); } + + this.repoListPending = this.doSendRepoList(forceDiscovery).finally(() => { this.repoListPending = null; }); return this.repoListPending; } @@ -1863,6 +1944,9 @@ export class MainPanel { for (const f of vscode.workspace.workspaceFolders ?? []) { workspacePaths.add(f.uri.fsPath); } + for (const repoPath of await this.getVsCodeGitRepoPaths()) { + workspacePaths.add(repoPath); + } workspacePaths.add(this.repoPath); const discovered = await RepoDiscoveryService.discoverRepos([...workspacePaths]); // Canonicalize every path to VS Code's fsPath so the repo list and the @@ -1876,7 +1960,7 @@ export class MainPanel { let active = vscode.Uri.file(this.repoPath).fsPath; this.repoPath = active; - if (repos.length > 0 && !repos.some(r => r.path === active)) { + if (repos.length > 0 && !repos.some(r => samePath(r.path, active))) { // Current path is not a repo, switch to the first discovered one active = repos[0].path; this.swapRepo(active); diff --git a/src/panels/__tests__/MainPanel.test.ts b/src/panels/__tests__/MainPanel.test.ts index 76051ee..bf57af7 100644 --- a/src/panels/__tests__/MainPanel.test.ts +++ b/src/panels/__tests__/MainPanel.test.ts @@ -23,6 +23,9 @@ const H = vi.hoisted(() => { getConflictFiles: vi.fn(async () => []), getOperationState: vi.fn(async () => ({ type: null })), getRemoteUrl: vi.fn(async () => ''), + getReflog: vi.fn(async () => ({ entries: [], hasMore: false })), + statsCommitsByAuthor: vi.fn(async () => []), + statsCommitsByWeekdayHour: vi.fn(async () => []), stashSave: vi.fn(async () => {}), checkout: vi.fn(async () => {}), pull: vi.fn(async () => {}), @@ -37,6 +40,7 @@ const H = vi.hoisted(() => { messageHandler: null as null | ((m: unknown) => unknown), panel: null as null | { webview: { postMessage: ReturnType } }, repos: [] as Array<{ path: string; name: string; type: string }>, + gitApiRepos: [] as string[], }; }); @@ -77,6 +81,21 @@ vi.mock('vscode', () => { fs: { writeFile: vi.fn(async () => {}) }, }, commands: { executeCommand: vi.fn() }, + extensions: { + getExtension: vi.fn((id: string) => { + if (id !== 'vscode.git') return undefined; + const extensionExports = { + getAPI: () => ({ + repositories: H.gitApiRepos.map(repoPath => ({ rootUri: { fsPath: repoPath } })), + }), + }; + return { + isActive: true, + exports: extensionExports, + activate: vi.fn(async () => extensionExports), + }; + }), + }, l10n: { t: (k: string) => k }, env: { language: 'en', clipboard: { writeText: vi.fn() } }, Uri: { @@ -98,6 +117,7 @@ vi.mock('../../git/vscode-git-bridge', () => ({ triggerVSCodeGitAuth: vi.fn(asyn import { MainPanel } from '../MainPanel'; import { GitError } from '../../git/git-service'; +import { RepoDiscoveryService } from '../../services/repo-discovery'; const extUri = { fsPath: '/ext' } as unknown as import('vscode').Uri; @@ -124,10 +144,14 @@ beforeEach(() => { H.git.getOperationState.mockResolvedValue({ type: null }); H.git.getConflictFiles.mockResolvedValue([]); H.git.getRemoteUrl.mockResolvedValue(''); + H.git.getReflog.mockResolvedValue({ entries: [], hasMore: false }); + H.git.statsCommitsByAuthor.mockResolvedValue([]); + H.git.statsCommitsByWeekdayHour.mockResolvedValue([]); H.git.showCommitDiff.mockResolvedValue([]); H.git.fileExistsAtRef.mockResolvedValue(true); H.git.getEmptyTreeRef.mockResolvedValue('4b825dc642cb6eb9a060e54bf8d69288fbee4904'); H.repos = [{ path: '/repo', name: 'repo', type: 'root' }]; + H.gitApiRepos = []; (MainPanel as unknown as { currentPanel: unknown }).currentPanel = undefined; MainPanel.createOrShow(extUri, '/repo'); }); @@ -261,6 +285,23 @@ describe('MainPanel message routing', () => { expect(data.payload).toHaveProperty('worktrees'); }); + it('includes repositories already discovered by VS Code Git in the repo list scan', async () => { + H.gitApiRepos = ['/workspace/apps/deep/repo-b']; + H.repos = [ + { path: '/repo', name: 'repo', type: 'root' }, + { path: '/workspace/apps/deep/repo-b', name: 'repo-b', type: 'root' }, + ]; + + await dispatch({ type: 'getRepoList' }); + + expect(RepoDiscoveryService.discoverRepos).toHaveBeenLastCalledWith( + expect.arrayContaining(['/workspace/apps/deep/repo-b']), + ); + const data = postedOfType('repoList').at(-1)!; + const repoPaths = (data.payload!.repos as Array<{ path: string }>).map(repo => repo.path); + expect(repoPaths).toContain('/workspace/apps/deep/repo-b'); + }); + it('getCommitDiff posts the file list for the commit', async () => { H.git.showCommitFiles.mockResolvedValue([{ path: 'a.ts', status: 'M' }]); await dispatch({ type: 'getCommitDiff', payload: { hash: 'h1' } }); @@ -287,6 +328,18 @@ describe('MainPanel message routing', () => { await dispatch({ type: 'switchRepo', payload: { path: '/somewhere/else' } }); expect(postedOfType('error').length).toBeGreaterThan(0); }); + + it('notifies repo-bound tabs when switching repositories', async () => { + H.repos = [ + { path: '/repo', name: 'repo', type: 'root' }, + { path: '/repo-b', name: 'repo-b', type: 'nested' }, + ]; + await dispatch({ type: 'getRepoList' }); + + await dispatch({ type: 'switchRepo', payload: { path: '/repo-b' } }); + + expect(postedOfType('repoChanged').some(m => m.payload?.what === 'repo')).toBe(true); + }); }); describe('MainPanel error handling', () => { @@ -409,6 +462,48 @@ describe('MainPanel orchestration logic', () => { expect(logs.some(l => (l.payload!.commits as Array<{ hash: string }>).some(c => c.hash === 'aaaaaaa1'))).toBe(false); }); + it('discards stale reflog and stats responses from the previous repo after switching repos', async () => { + H.repos = [ + { path: '/repo', name: 'repo', type: 'root' }, + { path: '/repo-b', name: 'repo-b', type: 'nested' }, + ]; + await dispatch({ type: 'getRepoList' }); + + let resolveOldReflog!: (v: unknown) => void; + let resolveOldAuthors!: (v: unknown) => void; + let resolveOldHours!: (v: unknown) => void; + + H.git.getReflog + .mockImplementationOnce(() => new Promise(r => { resolveOldReflog = r as (v: unknown) => void; })) + .mockResolvedValue({ entries: [{ hash: 'bbbbbbb2', shortHash: 'bbbbbbb', selector: 'HEAD@{0}', message: 'commit: new', date: '', dangling: false }], hasMore: false }); + H.git.statsCommitsByAuthor + .mockImplementationOnce(() => new Promise(r => { resolveOldAuthors = r as (v: unknown) => void; })) + .mockResolvedValue([{ author: 'New', email: 'new@example.com', count: 1 }]); + H.git.statsCommitsByWeekdayHour + .mockImplementationOnce(() => new Promise(r => { resolveOldHours = r as (v: unknown) => void; })) + .mockResolvedValue([{ weekday: 1, hour: 9, count: 1 }]); + + const pOldReflog = dispatch({ type: 'getReflog', payload: {} }); + const pOldStats = dispatch({ type: 'getStats' }); + + await dispatch({ type: 'switchRepo', payload: { path: '/repo-b' } }); + await dispatch({ type: 'getReflog', payload: {} }); + await dispatch({ type: 'getStats' }); + + resolveOldReflog({ entries: [{ hash: 'aaaaaaa1', shortHash: 'aaaaaaa', selector: 'HEAD@{0}', message: 'commit: old', date: '', dangling: false }], hasMore: false }); + resolveOldAuthors([{ author: 'Old', email: 'old@example.com', count: 1 }]); + resolveOldHours([{ weekday: 0, hour: 0, count: 1 }]); + await Promise.all([pOldReflog, pOldStats]); + + const reflogs = postedOfType('reflogData'); + expect(reflogs.some(m => (m.payload!.entries as Array<{ hash: string }>).some(e => e.hash === 'aaaaaaa1'))).toBe(false); + expect((reflogs.at(-1)!.payload!.entries as Array<{ hash: string }>)[0].hash).toBe('bbbbbbb2'); + + const stats = postedOfType('statsData'); + expect(stats.some(m => (m.payload!.byAuthor as Array<{ author: string }>).some(a => a.author === 'Old'))).toBe(false); + expect((stats.at(-1)!.payload!.byAuthor as Array<{ author: string }>)[0].author).toBe('New'); + }); + it('refreshAll applies the saved filter before the first getLog so it does not flash the full unfiltered graph', async () => { const M = MainPanel as unknown as { savedRemoteFilter?: string[]; savedBranchFilter?: string[] }; const prevRemote = M.savedRemoteFilter; diff --git a/src/utils/message-bus.ts b/src/utils/message-bus.ts index deec2ab..b86e9ca 100644 --- a/src/utils/message-bus.ts +++ b/src/utils/message-bus.ts @@ -126,6 +126,7 @@ export type WebviewMessage = | { type: 'getUncommittedFileDiff'; payload: { file: string; staged: boolean } } | { type: 'getMultiCommitSections'; payload: { hashes: string[] } } | { type: 'getAvatar'; payload: { email: string; size: number } } + | { type: 'openRemoteRepository'; payload?: { remote?: string } } | { type: 'openExternalUrl'; payload: { url: string } } | { type: 'openExtensionSettings' }; @@ -135,6 +136,7 @@ export type ExtensionMessage = | { type: 'branchData'; payload: BranchData } | { type: 'fullRefresh'; payload: { logData: CommitGraphData; branchData: BranchData } } | { type: 'commitDiffData'; payload: { hash?: string; diffs?: DiffData[]; files: Array<{ path: string; status: string }> } } + | { type: 'fileDiffData'; payload: { hash: string; file: string; key?: string; diff: DiffData | null } } | { type: 'commitSignatureData'; payload: { hash: string; signature: CommitSignature } } | { type: 'rebaseCommitsData'; payload: { base: string; commits: Commit[] } } | { type: 'searchResults'; payload: CommitGraphData } diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index 6050171..13fd799 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -985,7 +985,6 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -1335,7 +1334,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1718,7 +1716,6 @@ "integrity": "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", @@ -2037,7 +2034,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2300,7 +2296,6 @@ "integrity": "sha512-ymI5ykLPwIHW839E053FQbI1G+jnRFJEw3Kv5Y4njixVWywQBx+NUFpkkKyk5LIb36Fg9DVXSYpqiGekLD0hyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -2424,7 +2419,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2542,7 +2536,6 @@ "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -2661,7 +2654,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/webview-ui/src/App.svelte b/webview-ui/src/App.svelte index c649320..38a32b6 100644 --- a/webview-ui/src/App.svelte +++ b/webview-ui/src/App.svelte @@ -4,7 +4,7 @@ import { getVsCodeApi } from './lib/vscode-api'; import { commitStore } from './lib/stores/commits.svelte'; import { branchStore } from './lib/stores/branches.svelte'; - import { uiStore, BOTTOM_PANEL_DEFAULT_RATIO, BOTTOM_PANEL_MIN_RATIO, BOTTOM_PANEL_MAX_RATIO } from './lib/stores/ui.svelte'; + import { uiStore, BOTTOM_PANEL_MIN_RATIO, BOTTOM_PANEL_MAX_RATIO } from './lib/stores/ui.svelte'; import { i18n, t } from './lib/i18n/index.svelte'; import CommitGraph from './components/graph/CommitGraph.svelte'; import BottomPanel from './components/layout/BottomPanel.svelte'; @@ -49,6 +49,7 @@ import AmendModal from './components/modals/AmendModal.svelte'; import { tooltip } from './lib/actions/tooltip'; import DirtyActionModal from './components/modals/DirtyActionModal.svelte'; import { dragRebaseMessage, dragMergeMessage } from './lib/utils/dragDrop'; + import { warmHighlighterLanguages } from './lib/utils/highlighter'; const vscode = getVsCodeApi(); @@ -65,6 +66,20 @@ import AmendModal from './components/modals/AmendModal.svelte'; let rebasePaused = $state(false); let showAbortConfirmModal = $state(false); + function scheduleHighlighterWarmup(): () => void { + const run = () => warmHighlighterLanguages(['vue', 'svelte', 'astro']); + const win = window as Window & { + requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number; + cancelIdleCallback?: (handle: number) => void; + }; + if (win.requestIdleCallback) { + const handle = win.requestIdleCallback(run, { timeout: 2500 }); + return () => win.cancelIdleCallback?.(handle); + } + const handle = window.setTimeout(run, 1000); + return () => window.clearTimeout(handle); + } + // Non-shared modals (unique to Activity Bar) let showStashDropModal = $state(false); let stashDropIndex = $state(0); @@ -83,8 +98,6 @@ import AmendModal from './components/modals/AmendModal.svelte'; }); onMount(() => { - uiStore.bottomPanelHeight = Math.round(window.innerHeight * BOTTOM_PANEL_DEFAULT_RATIO); - function handleMessage(event: MessageEvent) { const msg = event.data; switch (msg.type) { @@ -216,11 +229,13 @@ import AmendModal from './components/modals/AmendModal.svelte'; } window.addEventListener('message', handleMessage); + const cancelHighlighterWarmup = scheduleHighlighterWarmup(); // Request initial data commitStore.setLoading(true); vscode.postMessage({ type: 'getLog', payload: {} }); vscode.postMessage({ type: 'getBranches' }); + vscode.postMessage({ type: 'getRepoList' }); vscode.postMessage({ type: 'checkFlowStatus' }); // Refresh conflict status when webview becomes visible @@ -239,6 +254,7 @@ import AmendModal from './components/modals/AmendModal.svelte'; window.removeEventListener('keydown', handleGlobalKeydown); document.removeEventListener('visibilitychange', handleVisibility); window.removeEventListener('focus', handleVisibility); + cancelHighlighterWarmup(); }; }); @@ -341,11 +357,14 @@ import AmendModal from './components/modals/AmendModal.svelte'; e.preventDefault(); resizing = true; const startY = e.clientY; - const startHeight = uiStore.bottomPanelHeight; + const startRatio = uiStore.bottomPanelRatio; + const container = (e.currentTarget as HTMLElement).parentElement; + const containerHeight = container?.clientHeight || window.innerHeight; function onMouseMove(e: MouseEvent) { const delta = startY - e.clientY; - uiStore.bottomPanelHeight = Math.max(window.innerHeight * BOTTOM_PANEL_MIN_RATIO, Math.min(window.innerHeight * BOTTOM_PANEL_MAX_RATIO, startHeight + delta)); + const nextRatio = startRatio + delta / containerHeight; + uiStore.bottomPanelRatio = Math.max(BOTTOM_PANEL_MIN_RATIO, Math.min(BOTTOM_PANEL_MAX_RATIO, nextRatio)); } function onMouseUp() { @@ -498,7 +517,7 @@ import AmendModal from './components/modals/AmendModal.svelte';
{/if} -
+
{/if} @@ -1119,11 +1138,6 @@ import AmendModal from './components/modals/AmendModal.svelte'; .bottom-area { overflow: hidden; flex-shrink: 0; - /* bottomPanelHeight is a fixed px value that isn't recomputed when the - viewport shrinks (e.g. opening the terminal). Cap the panel to the - available area so it can never overflow .content-area and get clipped; - BottomPanel then scrolls internally instead of becoming unreachable. */ - max-height: 80%; border-top: 1px solid var(--border-color); } diff --git a/webview-ui/src/__tests__/App.test.ts b/webview-ui/src/__tests__/App.test.ts index 69b013b..f6e70a6 100644 --- a/webview-ui/src/__tests__/App.test.ts +++ b/webview-ui/src/__tests__/App.test.ts @@ -4,7 +4,7 @@ import App from '../App.svelte'; import { i18n } from '../lib/i18n/index.svelte'; import { commitStore } from '../lib/stores/commits.svelte'; import { branchStore } from '../lib/stores/branches.svelte'; -import { uiStore } from '../lib/stores/ui.svelte'; +import { uiStore, BOTTOM_PANEL_DEFAULT_RATIO } from '../lib/stores/ui.svelte'; import { modalStore } from '../lib/stores/modals.svelte'; function postMsg(type: string, payload?: unknown) { @@ -25,6 +25,7 @@ function resetStores() { uiStore.comparing = false; uiStore.commitDetailFullscreen = false; uiStore.showBottomPanel = true; + uiStore.bottomPanelRatio = BOTTOM_PANEL_DEFAULT_RATIO; uiStore.commitFileSelected = false; uiStore.repos = []; uiStore.activeRepo = ''; @@ -51,12 +52,13 @@ afterEach(() => { }); describe('App — initial requests', () => { - it('posts getLog, getBranches, and checkFlowStatus on mount', async () => { + it('posts getLog, getBranches, getRepoList, and checkFlowStatus on mount', async () => { render(App); await waitFor(() => { const types = globalThis.__postedMessages.map(m => (m.data as { type?: string }).type); expect(types).toContain('getLog'); expect(types).toContain('getBranches'); + expect(types).toContain('getRepoList'); expect(types).toContain('checkFlowStatus'); }); }); @@ -1076,11 +1078,11 @@ describe('App — bottom panel resize handle', () => { const { container } = render(App); await waitFor(() => container.querySelector('.resize-handle-h')); const handle = container.querySelector('.resize-handle-h')!; - const startHeight = uiStore.bottomPanelHeight; + const startRatio = uiStore.bottomPanelRatio; await fireEvent.mouseDown(handle, { clientY: 500 }); - // Move up → height increases (deltaY negative from window.innerHeight - clientY) + // Move up → bottom panel ratio increases. await fireEvent.mouseMove(window, { clientY: 400 }); - expect(uiStore.bottomPanelHeight).not.toBe(startHeight); + expect(uiStore.bottomPanelRatio).toBeGreaterThan(startRatio); await fireEvent.mouseUp(window); }); }); diff --git a/webview-ui/src/components/commit/CommitDetails.svelte b/webview-ui/src/components/commit/CommitDetails.svelte index a9eb9b4..b202b1c 100644 --- a/webview-ui/src/components/commit/CommitDetails.svelte +++ b/webview-ui/src/components/commit/CommitDetails.svelte @@ -178,12 +178,12 @@ // passed to FileDiffView and the tree's "Reverse File" action. const canReverseInThisView = $derived(!!commit && stashIndex === null); - let filesPanelWidth = $state(240); + let filesPanelWidth = $state(300); let isResizing = $state(false); let resizeStartX = 0; let resizeStartWidth = 0; // svelte-ignore state_referenced_locally - let activeTab = $state<'commit' | 'changes'>(commit ? 'commit' : 'changes'); + let activeTab = $state<'commit' | 'changes'>('changes'); let uncommittedTab = $state<'staged' | 'unstaged'>('staged'); let activeHash = $state(''); @@ -264,6 +264,7 @@ activeTab = 'changes'; vscode.postMessage({ type: 'getUncommittedDiff' }); } else if (hash) { + activeTab = 'changes'; vscode.postMessage({ type: 'getCommitDiff', payload: { hash } }); vscode.postMessage({ type: 'getLfsFiles' }); vscode.postMessage({ type: 'getCommitSignature', payload: { hash } }); @@ -460,7 +461,28 @@ return nodes; } - return sortTree(root.children); + function compactDirectoryChains(nodes: FileTreeNode[]): FileTreeNode[] { + return nodes.map((node) => { + if (node.isFile) return node; + + const names = [node.name]; + let current = node; + while (true) { + const onlyChild = current.children.length === 1 ? current.children[0] : undefined; + if (!onlyChild || onlyChild.isFile) break; + current = onlyChild; + names.push(current.name); + } + + return { + ...current, + name: names.join('/'), + children: compactDirectoryChains(current.children), + }; + }); + } + + return compactDirectoryChains(sortTree(root.children)); } // All changed-file paths under a tree node (the node itself if it's a file). diff --git a/webview-ui/src/components/commit/FileDiffView.svelte b/webview-ui/src/components/commit/FileDiffView.svelte index 8e4ee8e..4f196c2 100644 --- a/webview-ui/src/components/commit/FileDiffView.svelte +++ b/webview-ui/src/components/commit/FileDiffView.svelte @@ -2,7 +2,15 @@ import type { DiffData } from '../../lib/types'; import { onMount } from 'svelte'; import { t } from '../../lib/i18n/index.svelte'; - import { detectLanguage, highlightLineSync, getHighlighter, ensureLanguage, activeShikiTheme, escapeHtml } from '../../lib/utils/highlighter'; + import { + detectLanguage, + highlightLineSync, + highlightCodeLinesSync, + getHighlighter, + ensureLanguage, + activeShikiTheme, + escapeHtml, + } from '../../lib/utils/highlighter'; import ImageDiff from '../common/ImageDiff.svelte'; // Right-click target on a diff line. The parent owns the context menu (it @@ -261,6 +269,7 @@ }); const MAX_HIGHLIGHT_LINES = 5000; + const MAX_FULL_FILE_HIGHLIGHT_LINES = 8000; // Tracks the VS Code color theme so highlighting re-runs (with the matching // light/dark token colours) when the user switches themes mid-session. @@ -280,8 +289,9 @@ $effect(() => { if (!diff || diff.isBinary) return; - const lang = detectLanguage(diff.file); - if (!lang) { + const newLang = detectLanguage(diff.content?.newPath ?? diff.newFile ?? diff.file); + const oldLang = detectLanguage(diff.content?.oldPath ?? diff.oldFile ?? diff.file); + if (!newLang && !oldLang) { highlightedLines = new Map(); return; } @@ -296,6 +306,9 @@ // shown. Toggling showFullDiff changes renderHunks and re-runs this effect, // so the revealed lines get highlighted then. const visibleHunks = renderHunks; + const mode = diffMode; + const needsOldSide = needsHighlightedSide(visibleHunks, 'old', mode); + const needsNewSide = needsHighlightedSide(visibleHunks, 'new', mode); const theme = shikiTheme; // capture so a theme switch invalidates the pass // Yield to the event loop between chunks so a multi-thousand-line diff // doesn't freeze the panel. Each batch processes CHUNK_SIZE lines then @@ -305,6 +318,30 @@ getHighlighter() .then(async h => { if (cancelled || diff !== target) return; + if (target.content) { + const oldLines = countSourceLines(target.content.oldText); + const newLines = countSourceLines(target.content.newText); + const linesToHighlight = (needsOldSide ? oldLines : 0) + (needsNewSide ? newLines : 0); + if (linesToHighlight <= MAX_FULL_FILE_HIGHLIGHT_LINES) { + const [oldReady, newReady] = await Promise.all([ + needsOldSide && oldLang ? ensureLanguage(h, oldLang) : Promise.resolve(false), + needsNewSide && newLang ? ensureLanguage(h, newLang) : Promise.resolve(false), + ]); + if (cancelled || diff !== target) return; + const newMap = new Map(); + if (needsOldSide && oldReady) { + highlightCodeLinesSync(h, target.content.oldText, oldLang, theme) + .forEach((html, idx) => newMap.set(`old:${idx + 1}`, html)); + } + if (needsNewSide && newReady) { + highlightCodeLinesSync(h, target.content.newText, newLang, theme) + .forEach((html, idx) => newMap.set(`new:${idx + 1}`, html)); + } + highlightedLines = newMap; + return; + } + } + const lang = newLang || oldLang; // Grammars load on demand; bail out to plain escaping if this language // has no Shiki grammar (highlightLineSync would fall back anyway, but // skipping the loop avoids a pointless full pass over the diff). @@ -337,7 +374,44 @@ return () => { cancelled = true; }; }); - function getHighlighted(hunkStart: number, lineIdx: number, content: string): string { + function needsHighlightedSide( + hunks: DiffData['hunks'], + side: 'old' | 'new', + mode: 'inline' | 'side-by-side', + ): boolean { + for (const hunk of hunks) { + for (const line of hunk.lines) { + if (side === 'old') { + if (line.type === 'delete') return true; + if (mode === 'side-by-side' && line.type === 'context') return true; + } else if (line.type === 'add' || line.type === 'context') { + return true; + } + } + } + return false; + } + + function countSourceLines(text: string): number { + if (!text) return 0; + const lines = text.split(/\r\n|\r|\n/); + return /(?:\r\n|\r|\n)$/.test(text) ? lines.length - 1 : lines.length; + } + + function getHighlighted( + hunkStart: number, + lineIdx: number, + content: string, + line?: DiffData['hunks'][number]['lines'][number], + sideHint: 'old' | 'new' = 'new', + ): string { + if (line && diff.content) { + const side = line.type === 'delete' ? 'old' : line.type === 'add' ? 'new' : sideHint; + const lineNumber = side === 'old' ? line.oldLineNumber : line.newLineNumber; + if (lineNumber !== undefined) { + return highlightedLines.get(`${side}:${lineNumber}`) ?? escapeHtml(content); + } + } const key = `${hunkStart}-${lineIdx}`; return highlightedLines.get(key) ?? escapeHtml(content); } @@ -420,7 +494,7 @@ {line.type === 'add' ? '+' : line.type === 'delete' ? '-' : ' '} - { if (e.button === 0) lineSel = null; }}>{@html getHighlighted(hunk.oldStart, lineIndex, line.content)} + { if (e.button === 0) lineSel = null; }}>{@html getHighlighted(hunk.oldStart, lineIndex, line.content, line, line.type === 'delete' ? 'old' : 'new')}
{/each} @@ -448,7 +522,7 @@ {#if line.type === 'context' || line.type === 'delete'}
{line.oldLineNumber ?? ''} - {@html getHighlighted(hunk.oldStart, lineIndex, line.content)} + {@html getHighlighted(hunk.oldStart, lineIndex, line.content, line, 'old')}
{:else}
@@ -477,7 +551,7 @@ {#if line.type === 'context' || line.type === 'add'}
{line.newLineNumber ?? ''} - {@html getHighlighted(hunk.oldStart, lineIndex, line.content)} + {@html getHighlighted(hunk.oldStart, lineIndex, line.content, line, 'new')}
{:else}
diff --git a/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts b/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts index 9b04ec1..d761050 100644 --- a/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts +++ b/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts @@ -51,6 +51,14 @@ function deliverSignature(hash: string, signature: { status: 'good' | 'none' | ' })); } +async function openCommitTab(container: HTMLElement): Promise { + const commitTab = Array.from(container.querySelectorAll('.top-tab')) + .find(tab => /commit/i.test(tab.textContent ?? '')); + expect(commitTab).toBeTruthy(); + await fireEvent.click(commitTab!); + return commitTab!; +} + beforeEach(() => { i18n.setLocale('en'); globalThis.__postedMessages = []; @@ -116,6 +124,7 @@ describe('CommitDetails — request flow', () => { describe('CommitDetails — signature', () => { it('shows the verified signer (enriched with the committer name) and key ID', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.person-name')); deliverSignature('h1', { status: 'good', signer: 'Alice', keyId: 'ABCD1234' }); await waitFor(() => expect(container.querySelector('.sig-detail')).toBeTruthy()); @@ -128,6 +137,7 @@ describe('CommitDetails — signature', () => { it('labels an SSH key fingerprint as "SSH Key" instead of "GPG Key ID"', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.person-name')); deliverSignature('h1', { status: 'good', signer: 'a@x.com', keyId: 'SHA256:AbCdEf' }); await waitFor(() => expect(container.querySelector('.sig-detail')).toBeTruthy()); @@ -138,6 +148,7 @@ describe('CommitDetails — signature', () => { it('does not fabricate a signer from the committer for an unverified signature', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.person-name')); // git returns no %GS for an untrusted key — must not claim "Signed by ". deliverSignature('h1', { status: 'unverified', keyId: 'SHA256:UnTrust' }); @@ -150,6 +161,7 @@ describe('CommitDetails — signature', () => { it('shows no signature row or glyph for an unsigned commit', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.person-name')); deliverSignature('h1', { status: 'none' }); await Promise.resolve(); @@ -159,6 +171,7 @@ describe('CommitDetails — signature', () => { it('ignores a stale signature response for a different commit', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.person-name')); deliverSignature('other-hash', { status: 'good', signer: 'Mallory' }); await Promise.resolve(); @@ -170,6 +183,7 @@ describe('CommitDetails — signature', () => { describe('CommitDetails — commit info rendering', () => { it('renders author name, email, and short hash', async () => { const { container } = render(CommitDetails, { commit: commit() }); + await openCommitTab(container); await waitFor(() => container.querySelector('.person-name')); const text = container.textContent ?? ''; expect(text).toContain('Alice'); @@ -177,19 +191,21 @@ describe('CommitDetails — commit info rendering', () => { expect(text).toContain('abcdef1'); // short hash }); - it('omits the committer column when committer matches author', () => { + it('omits the committer column when committer matches author', async () => { const { container } = render(CommitDetails, { commit: commit() }); + await openCommitTab(container); const labels = Array.from(container.querySelectorAll('.info-label')).map(el => el.textContent?.trim()); expect(labels).toContain('Author'); expect(labels).not.toContain('Committer'); }); - it('shows the committer column when committer differs from author', () => { + it('shows the committer column when committer differs from author', async () => { const { container } = render(CommitDetails, { commit: commit({ committer: { name: 'Bob', email: 'b@x.com', date: '2024-01-15T11:00:00Z' }, }), }); + await openCommitTab(container); const labels = Array.from(container.querySelectorAll('.info-label')).map(el => el.textContent?.trim()); expect(labels).toContain('Committer'); }); @@ -219,14 +235,15 @@ describe('CommitDetails — files list', () => { }); describe('CommitDetails — tabs', () => { - it('default tab is "commit" when a real commit is selected', () => { + it('default tab is "changes" when a real commit is selected', () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); const activeTab = container.querySelector('.top-tab.active')?.textContent ?? ''; - expect(activeTab.toLowerCase()).toContain('commit'); + expect(activeTab.toLowerCase()).toContain('changes'); }); it('clicking Changes tab activates it', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) }); + await openCommitTab(container); const tabs = container.querySelectorAll('.top-tab'); const changesTab = Array.from(tabs).find(t => /change/i.test(t.textContent ?? ''))!; await fireEvent.click(changesTab); @@ -305,6 +322,7 @@ describe('CommitDetails — empty / compare', () => { describe('CommitDetails — SHA copy buttons', () => { it('full SHA copy button posts copyToClipboard with the full hash', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'abcdef1234567890', abbreviatedHash: 'abcdef1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.copy-btns')); const btns = container.querySelectorAll('.copy-btn'); globalThis.__postedMessages = []; @@ -317,6 +335,7 @@ describe('CommitDetails — SHA copy buttons', () => { it('short SHA copy button posts copyToClipboard with the abbreviated hash', async () => { const { container } = render(CommitDetails, { commit: commit({ hash: 'abcdef1234567890', abbreviatedHash: 'abcdef1' }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.copy-btns')); const btns = container.querySelectorAll('.copy-btn'); globalThis.__postedMessages = []; @@ -331,12 +350,14 @@ describe('CommitDetails — SHA copy buttons', () => { describe('CommitDetails — parent links', () => { it('renders one parent-link button per parent', async () => { const { container } = render(CommitDetails, { commit: commit({ parents: ['p1', 'p2'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelectorAll('.parent-link').length === 2); expect(container.querySelectorAll('.parent-link').length).toBe(2); }); it('clicking a parent updates uiStore.selectedCommitHash and posts searchByHash', async () => { const { container } = render(CommitDetails, { commit: commit({ parents: ['parentHash1', 'parentHash2'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.parent-link')); globalThis.__postedMessages = []; await fireEvent.click(container.querySelector('.parent-link')!); @@ -346,15 +367,16 @@ describe('CommitDetails — parent links', () => { )).toBe(true); }); - it('omits the PARENTS row when commit has no parents', () => { + it('omits the PARENTS row when commit has no parents', async () => { const { container } = render(CommitDetails, { commit: commit({ parents: [] }) }); + await openCommitTab(container); const labels = Array.from(container.querySelectorAll('.meta-label')).map(el => el.textContent?.trim()); expect(labels).not.toContain('Parents'); }); }); describe('CommitDetails — refs rendering', () => { - it('renders REFS row with branch and tag badges', () => { + it('renders REFS row with branch and tag badges', async () => { const { container } = render(CommitDetails, { commit: commit({ refs: [ @@ -363,12 +385,13 @@ describe('CommitDetails — refs rendering', () => { ], }), }); + await openCommitTab(container); const text = container.querySelector('.meta-value')?.textContent ?? ''; expect(text).toMatch(/main/); expect(text).toMatch(/v1\.0/); }); - it('applies badge-head to the current branch and badge-fixed to tags', () => { + it('applies badge-head to the current branch and badge-fixed to tags', async () => { const { container } = render(CommitDetails, { commit: commit({ refs: [ @@ -378,6 +401,7 @@ describe('CommitDetails — refs rendering', () => { ], }), }); + await openCommitTab(container); const badges = Array.from(container.querySelectorAll('.ref-badge')); const byText = (txt: string) => badges.find(b => (b.textContent ?? '').includes(txt))!; expect(byText('main').classList.contains('badge-head')).toBe(true); @@ -388,7 +412,7 @@ describe('CommitDetails — refs rendering', () => { expect(byText('v1.0').classList.contains('badge-head')).toBe(false); }); - it('hides REFS row when only stash or remote HEAD refs exist', () => { + it('hides REFS row when only stash or remote HEAD refs exist', async () => { const { container } = render(CommitDetails, { commit: commit({ refs: [ @@ -397,6 +421,7 @@ describe('CommitDetails — refs rendering', () => { ], }), }); + await openCommitTab(container); const labels = Array.from(container.querySelectorAll('.meta-label')).map(el => el.textContent?.trim()); expect(labels).not.toContain('REFS'); }); @@ -715,6 +740,7 @@ describe('CommitDetails — parent hover preview', () => { it('mouseenter on a parent posts getCommitData when not cached', async () => { vi.useFakeTimers(); const { container } = render(CommitDetails, { commit: commit({ parents: ['parent1'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.parent-link')); globalThis.__postedMessages = []; await fireEvent.mouseEnter(container.querySelector('.parent-link')!, { clientX: 100, clientY: 100 }); @@ -731,6 +757,7 @@ describe('CommitDetails — parent hover preview', () => { vi.useFakeTimers(); commitStore.commits = [commit({ hash: 'parent1', subject: 'parent commit' })]; const { container } = render(CommitDetails, { commit: commit({ parents: ['parent1'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.parent-link')); globalThis.__postedMessages = []; await fireEvent.mouseEnter(container.querySelector('.parent-link')!, { clientX: 50, clientY: 50 }); @@ -745,6 +772,7 @@ describe('CommitDetails — parent hover preview', () => { it('mouseleave before delay cancels the preview', async () => { vi.useFakeTimers(); const { container } = render(CommitDetails, { commit: commit({ parents: ['parent1'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.parent-link')); const link = container.querySelector('.parent-link')!; await fireEvent.mouseEnter(link, { clientX: 100, clientY: 100 }); @@ -764,6 +792,7 @@ describe('CommitDetails — hover preview cache & navigate', () => { it('commitData message stores the commit in the preview cache and shows preview if hovering', async () => { vi.useFakeTimers(); const { container } = render(CommitDetails, { commit: commit({ parents: ['parent1'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.parent-link')); // Hover to set hoveredHash + previewPos await fireEvent.mouseEnter(container.querySelector('.parent-link')!, { clientX: 100, clientY: 100 }); @@ -784,6 +813,7 @@ describe('CommitDetails — hover preview cache & navigate', () => { vi.useFakeTimers(); commitStore.commits = [commit({ hash: 'parent1', subject: 'parent commit' })]; const { container } = render(CommitDetails, { commit: commit({ parents: ['parent1'] }) }); + await openCommitTab(container); await waitFor(() => container.querySelector('.parent-link')); // Cached commit → preview shows directly after delay await fireEvent.mouseEnter(container.querySelector('.parent-link')!, { clientX: 100, clientY: 100 }); @@ -1329,10 +1359,11 @@ describe('CommitDetails — markdown toggle', () => { i18n.setLocale('en'); }); - it('renders markdown by default when the message has markdown (bold subject becomes )', () => { + it('renders markdown by default when the message has markdown (bold subject becomes )', async () => { const { container } = render(CommitDetails, { commit: commit({ subject: '**bold** subject', body: '- one\n- two', parents: [] }), }); + await openCommitTab(container); expect(container.querySelector('.message-section strong')?.textContent).toBe('bold'); }); @@ -1340,6 +1371,7 @@ describe('CommitDetails — markdown toggle', () => { const { container, getByText } = render(CommitDetails, { commit: commit({ subject: '**bold** subject', body: '- one\n- two', parents: [] }), }); + await openCommitTab(container); await fireEvent.click(getByText('Plain Text')); expect(container.querySelector('.message-section strong')).toBeNull(); expect(container.querySelector('.message-section')?.textContent).toContain('**bold**'); @@ -1349,15 +1381,17 @@ describe('CommitDetails — markdown toggle', () => { const { container, getByText } = render(CommitDetails, { commit: commit({ subject: '**bold** subject', body: '- one\n- two', parents: [] }), }); + await openCommitTab(container); await fireEvent.click(getByText('Plain Text')); await fireEvent.click(getByText('Markdown')); expect(container.querySelector('.message-section strong')?.textContent).toBe('bold'); }); - it('hides the toggle and shows plain text when the message has no markdown', () => { + it('hides the toggle and shows plain text when the message has no markdown', async () => { const { container, queryByText } = render(CommitDetails, { commit: commit({ subject: 'Fix crash on startup', body: 'No markdown here.', parents: [] }), }); + await openCommitTab(container); expect(container.querySelector('.message-view-toggle')).toBeNull(); expect(queryByText('Markdown')).toBeNull(); expect(container.querySelector('.message-subject')?.textContent).toContain('Fix crash on startup'); diff --git a/webview-ui/src/components/common/Reflog.svelte b/webview-ui/src/components/common/Reflog.svelte index 2f8f018..70631ec 100644 --- a/webview-ui/src/components/common/Reflog.svelte +++ b/webview-ui/src/components/common/Reflog.svelte @@ -144,6 +144,21 @@ vscode.postMessage({ type: 'getReflog', payload: { ref: selectedRef, limit: currentLimit } }); } + function reloadForRepoSwitch() { + selectedRef = 'HEAD'; + currentLimit = 200; + entries = []; + hasMore = false; + loading = true; + loadingMore = false; + query = ''; + refOpen = false; + actionOpen = false; + activeActions = new Set(); + danglingOnly = false; + vscode.postMessage({ type: 'getReflog', payload: { ref: selectedRef, limit: currentLimit } }); + } + function changeRef(ref: string) { selectedRef = ref; currentLimit = 200; @@ -172,7 +187,12 @@ hasMore = msg.payload.hasMore; loading = false; loadingMore = false; - } else if (msg.type === 'repoChanged' || msg.type === 'operationComplete') { + } else if (msg.type === 'repoChanged') { + if (active) { + if (msg.payload?.what === 'repo') reloadForRepoSwitch(); + else load(); + } + } else if (msg.type === 'operationComplete') { if (active) load(); } } diff --git a/webview-ui/src/components/common/StatsView.svelte b/webview-ui/src/components/common/StatsView.svelte index 392770a..559bb2b 100644 --- a/webview-ui/src/components/common/StatsView.svelte +++ b/webview-ui/src/components/common/StatsView.svelte @@ -16,16 +16,25 @@ let loading = $state(true); let maxHeatCount = $derived(Math.max(1, ...byWeekdayHour.map(s => s.count))); + function load() { + byAuthor = []; + byWeekdayHour = []; + loading = true; + vscode.postMessage({ type: 'getStats' }); + } + onMount(() => { function handleMessage(event: MessageEvent) { if (event.data.type === 'statsData') { byAuthor = event.data.payload.byAuthor; byWeekdayHour = event.data.payload.byWeekdayHour; loading = false; + } else if (event.data.type === 'repoChanged') { + load(); } } window.addEventListener('message', handleMessage); - vscode.postMessage({ type: 'getStats' }); + load(); return () => window.removeEventListener('message', handleMessage); }); diff --git a/webview-ui/src/components/common/__tests__/Reflog.test.ts b/webview-ui/src/components/common/__tests__/Reflog.test.ts index 53c336e..da6f260 100644 --- a/webview-ui/src/components/common/__tests__/Reflog.test.ts +++ b/webview-ui/src/components/common/__tests__/Reflog.test.ts @@ -135,6 +135,32 @@ describe('Reflog — loading & data flow', () => { )).toBe(true); }); }); + + it('repo switch resets the selected ref and clears stale entries before reloading', async () => { + branchStore.branches = [ + { name: 'main', current: true, remote: undefined, upstream: undefined, ahead: 0, behind: 0, hash: 'h1' }, + { name: 'feat', current: false, remote: undefined, upstream: undefined, ahead: 0, behind: 0, hash: 'h2' }, + ]; + const { container } = render(Reflog, { active: true }); + deliverReflog([entry({ message: 'commit: old repo' })]); + + const filterBtns = container.querySelectorAll('.filter-btn'); + await fireEvent.click(filterBtns[0]); + const items = container.querySelectorAll('.dropdown .dd-item'); + await fireEvent.click(Array.from(items).find(b => b.textContent?.includes('feat'))!); + globalThis.__postedMessages = []; + + window.dispatchEvent(new MessageEvent('message', { data: { type: 'repoChanged', payload: { what: 'repo' } } })); + + await waitFor(() => { + expect(container.querySelector('.spinner')).not.toBeNull(); + expect(container.textContent).not.toContain('old repo'); + const req = globalThis.__postedMessages.find( + (m) => (m.data as { type?: string }).type === 'getReflog' + )!; + expect((req.data as { payload: { ref: string; limit: number } }).payload).toEqual({ ref: 'HEAD', limit: 200 }); + }); + }); }); describe('Reflog — search and filter', () => { diff --git a/webview-ui/src/components/common/__tests__/StatsView.test.ts b/webview-ui/src/components/common/__tests__/StatsView.test.ts index 3453aa6..5b26e99 100644 --- a/webview-ui/src/components/common/__tests__/StatsView.test.ts +++ b/webview-ui/src/components/common/__tests__/StatsView.test.ts @@ -62,4 +62,23 @@ describe('StatsView', () => { await waitFor(() => container.querySelector('.heatmap-legend')); expect(container.querySelectorAll('.heatmap-legend-cell').length).toBe(5); }); + + it('repoChanged clears stale stats and requests fresh data', async () => { + const { container } = render(StatsView); + deliverStats([{ author: 'Old Repo', email: 'old@example.com', count: 3 }]); + await waitFor(() => { + expect(container.textContent).toContain('Old Repo'); + }); + globalThis.__postedMessages = []; + + window.dispatchEvent(new MessageEvent('message', { data: { type: 'repoChanged', payload: { what: 'repo' } } })); + + await waitFor(() => { + expect(container.querySelector('.spinner')).not.toBeNull(); + expect(container.textContent).not.toContain('Old Repo'); + expect(globalThis.__postedMessages.some( + (m) => (m.data as { type?: string }).type === 'getStats' + )).toBe(true); + }); + }); }); diff --git a/webview-ui/src/components/layout/Toolbar.svelte b/webview-ui/src/components/layout/Toolbar.svelte index 6ceb091..113db59 100644 --- a/webview-ui/src/components/layout/Toolbar.svelte +++ b/webview-ui/src/components/layout/Toolbar.svelte @@ -56,6 +56,12 @@ modalStore.openPush(branchStore.remotes[0].name); } + function openRemoteRepository() { + if (branchStore.remotes.length === 0) { showNoRemotesError = true; return; } + const remote = branchStore.remotes.find(r => r.name === 'origin') ?? branchStore.remotes[0]; + vscode.postMessage({ type: 'openRemoteRepository', payload: { remote: remote.name } }); + } + function openFlowDropdown() { showFlowDropdown = !showFlowDropdown; if (showFlowDropdown) { @@ -230,6 +236,15 @@
+
@@ -90,6 +90,10 @@ margin: 12px 0; } + .modal-context-card { + flex-wrap: wrap; + } + .flow-step { display: flex; align-items: center; @@ -119,6 +123,21 @@ flex-shrink: 0; } + .flow-step-content { + display: flex; + align-items: center; + gap: 6px; + flex: 1; + flex-wrap: wrap; + min-width: 0; + } + + .modal-context-card :global(.modal-pill), + .flow-step :global(.modal-pill) { + max-width: 100%; + flex-shrink: 0; + } + .danger { background: var(--vscode-errorForeground, #f44336) !important; } diff --git a/webview-ui/src/lib/i18n/en.ts b/webview-ui/src/lib/i18n/en.ts index 716013a..cebf352 100644 --- a/webview-ui/src/lib/i18n/en.ts +++ b/webview-ui/src/lib/i18n/en.ts @@ -13,6 +13,7 @@ export const en: Record = { 'toolbar.pushDesc': 'Push (upload local commits)', 'toolbar.refreshDesc': 'Refresh (Ctrl+R)', 'toolbar.settings': 'Open Extension Settings', + 'toolbar.openRemoteRepository': 'Open Remote Repository in Browser', 'toolbar.noRemotes': 'No remotes configured. Add a remote first.', 'toolbar.addRemote': 'Add Remote', 'toolbar.detachedHead': '(Detached HEAD)', diff --git a/webview-ui/src/lib/i18n/ko.ts b/webview-ui/src/lib/i18n/ko.ts index 1137893..c76abb2 100644 --- a/webview-ui/src/lib/i18n/ko.ts +++ b/webview-ui/src/lib/i18n/ko.ts @@ -13,6 +13,7 @@ export const ko: Record = { 'toolbar.pushDesc': 'Push (로컬 commit 업로드)', 'toolbar.refreshDesc': '새로고침 (Ctrl+R)', 'toolbar.settings': '확장 프로그램 설정 열기', + 'toolbar.openRemoteRepository': '브라우저에서 원격 저장소 열기', 'toolbar.noRemotes': '설정된 리모트가 없습니다. 먼저 리모트를 추가하세요.', 'toolbar.addRemote': '리모트 추가', 'toolbar.detachedHead': '(Detached HEAD)', diff --git a/webview-ui/src/lib/i18n/zh.ts b/webview-ui/src/lib/i18n/zh.ts index 8d15a66..ff19534 100644 --- a/webview-ui/src/lib/i18n/zh.ts +++ b/webview-ui/src/lib/i18n/zh.ts @@ -13,6 +13,7 @@ export const zh: Record = { 'toolbar.pushDesc': '推送(上传本地提交)', 'toolbar.refreshDesc': '刷新(Ctrl+R)', 'toolbar.settings': '打开扩展设置', + 'toolbar.openRemoteRepository': '在浏览器打开远程仓库', 'toolbar.noRemotes': '未配置远程仓库。请先添加一个远程仓库。', 'toolbar.addRemote': '添加远程仓库', 'toolbar.detachedHead': '(分离头指针)', diff --git a/webview-ui/src/lib/stores/ui.svelte.ts b/webview-ui/src/lib/stores/ui.svelte.ts index 7df2ff1..8535cc7 100644 --- a/webview-ui/src/lib/stores/ui.svelte.ts +++ b/webview-ui/src/lib/stores/ui.svelte.ts @@ -1,6 +1,6 @@ import type { InteractiveRebaseMode } from '../types'; -export const BOTTOM_PANEL_DEFAULT_RATIO = 0.35; +export const BOTTOM_PANEL_DEFAULT_RATIO = 0.5; export const BOTTOM_PANEL_MIN_RATIO = 0.2; export const BOTTOM_PANEL_MAX_RATIO = 0.7; @@ -14,7 +14,7 @@ class UiStore { compareRef1 = $state(null); compareRef2 = $state(null); viewMode = $state<'graph' | 'log' | 'stats'>('graph'); - bottomPanelHeight = $state(250); + bottomPanelRatio = $state(BOTTOM_PANEL_DEFAULT_RATIO); showBottomPanel = $state(true); sidebarWidth = $state(220); errorMessage = $state(null); diff --git a/webview-ui/src/lib/types.ts b/webview-ui/src/lib/types.ts index 7830c01..f29f062 100644 --- a/webview-ui/src/lib/types.ts +++ b/webview-ui/src/lib/types.ts @@ -125,11 +125,21 @@ export interface BranchData { export interface DiffData { file: string; + oldFile?: string; + newFile?: string; + content?: DiffFileContent; hunks: DiffHunk[]; isBinary: boolean; isImage: boolean; } +export interface DiffFileContent { + oldText: string; + newText: string; + oldPath: string; + newPath: string; +} + export interface DiffHunk { header: string; oldStart: number; diff --git a/webview-ui/src/lib/utils/highlighter.ts b/webview-ui/src/lib/utils/highlighter.ts index 3128e1b..c306061 100644 --- a/webview-ui/src/lib/utils/highlighter.ts +++ b/webview-ui/src/lib/utils/highlighter.ts @@ -61,6 +61,16 @@ const LANG_LOADERS: Record Promise> = { make: () => import('shiki/langs/make.mjs'), }; +// Single-file component grammars delegate parts of the document to embedded +// languages. Load those eagerly with the container grammar so `