diff --git a/.oxlintrc.json b/.oxlintrc.json index 73faff3..b4117f8 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -66,6 +66,12 @@ "import/named": "off" } }, + { + "files": ["tests-ext/**/*.ts"], + "env": { + "mocha": true + } + }, { "files": ["src/webview/**/*.ts"], "env": { diff --git a/.vscode-test.mjs b/.vscode-test.mjs index ac1cc7a..d68a265 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,7 +1,7 @@ import { defineConfig } from "@vscode/test-cli"; export default defineConfig({ - files: "tests/out/extension/**/*.test.js", + files: "tests-ext/out/**/*.test.js", workspaceFolder: ".", version: "stable" }); diff --git a/esbuild.js b/esbuild.js index 91a74e5..a029593 100644 --- a/esbuild.js +++ b/esbuild.js @@ -33,7 +33,7 @@ const aliasPlugin = { async function main() { const extension = await esbuild.context({ - entryPoints: ["src/extension.ts"], + entryPoints: ["src/extension/main.ts"], bundle: true, format: "cjs", minify: production, diff --git a/package.json b/package.json index 59b75eb..c808a52 100644 --- a/package.json +++ b/package.json @@ -36,14 +36,14 @@ "publisher": "asispts", "main": "./out/extension.js", "scripts": { - "typecheck": "tsc -p ./src --noEmit && tsc -p ./src/webview --noEmit && tsc -p ./tests --noEmit", + "typecheck": "tsc -p ./src --noEmit && tsc -p ./src/webview --noEmit && tsc -p ./tests --noEmit && tsc -p ./tests-ext --noEmit", "vscode:prepublish": "pnpm run clean && pnpm run package", "package": "pnpm run typecheck && pnpm run lint && node esbuild.js --production", "clean": "node -e \"require('fs').rmSync('out',{recursive:true,force:true})\"", "compile": "pnpm run clean && node esbuild.js", - "test": "vitest run --project backend && vitest run --project webview", + "test": "vitest run --project backend && vitest run --project extension && vitest run --project webview", "test:ext": "pnpm run compile && pnpm run compile-tests && vscode-test", - "compile-tests": "tsc -p tests/extension/tsconfig.json && tsc-alias -p tests/extension/tsconfig.json", + "compile-tests": "tsc -p tests-ext/tsconfig.json && tsc-alias -p tests-ext/tsconfig.json", "watch:esbuild": "node esbuild.js --watch", "watch:tsc": "tsc -p ./src --noEmit --watch", "format": "oxfmt --check", diff --git a/src/backend/queries/repoSearch.ts b/src/backend/queries/repoSearch.ts new file mode 100644 index 0000000..d31c245 --- /dev/null +++ b/src/backend/queries/repoSearch.ts @@ -0,0 +1,12 @@ +import { searchDirectoryForRepos } from "@/backend/utils/repoSearch"; + +export async function findGitRepos( + paths: string[], + gitPath: string, + maxDepth: number +): Promise { + const results = await Promise.all( + paths.map((p) => searchDirectoryForRepos(p, maxDepth, gitPath, [])) + ); + return results.flat(); +} diff --git a/src/extension.ts b/src/extension.ts index cd4afe4..e571fba 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,6 +17,9 @@ import { initL10n } from "./l10n"; import { RepoFileWatcher } from "./repoFileWatcher"; import { StatusBarItem } from "./statusBarItem"; +/** + * @deprecated See src/extension/main.ts + */ export function activate(context: vscode.ExtensionContext) { initL10n(context.extensionPath); const outputChannel = vscode.window.createOutputChannel(l10n.t("outputChannel.text")); diff --git a/src/extension/initExtension.ts b/src/extension/initExtension.ts new file mode 100644 index 0000000..e8c4b2f --- /dev/null +++ b/src/extension/initExtension.ts @@ -0,0 +1,155 @@ +import * as path from "path"; + +import * as vscode from "vscode"; + +import { AvatarManager } from "@/avatarManager"; +import { GitClient, gitClientFactory } from "@/backend/gitClient"; +import { findGitRepos } from "@/backend/queries/repoSearch"; +import { buildExtensionUri } from "@/backend/utils/path"; +import { config } from "@/config"; +import { DiffDocProvider } from "@/diffDocProvider"; +import { createMaxDepthTracker } from "@/extension/maxDepthTracker"; +import { registerMessageHandlers } from "@/extension/messageHandler"; +import { createRepoManager, RepoManager } from "@/extension/repoManager"; +import { WebviewBridge, webviewBridgeFactory } from "@/extension/webviewBridge"; +import { createWebviewPanel, WebviewPanel } from "@/extension/webviewPanel"; +import { ExtensionState } from "@/extensionState"; +import * as l10n from "@/l10n"; +import { RepoFileWatcher } from "@/repoFileWatcher"; +import { StatusBarItem } from "@/statusBarItem"; + +export type InitExtension = typeof initExtension; + +function registerViewCommand( + ctx: vscode.ExtensionContext, + repoManager: RepoManager, + extensionState: ExtensionState, + avatarManager: AvatarManager, + gitClient: GitClient +) { + let currentPanel: WebviewPanel | undefined; + ctx.subscriptions.push( + vscode.commands.registerCommand("neo-git-graph.view", () => { + if (currentPanel) { + currentPanel.reveal(vscode.window.activeTextEditor?.viewColumn); + return; + } + + const vsPanel = vscode.window.createWebviewPanel( + "neo-git-graph", + l10n.t("outputChannel.text"), + vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [ + buildExtensionUri(ctx.extensionPath, "media"), + buildExtensionUri(ctx.extensionPath, "out") + ] + } + ); + + let bridge!: WebviewBridge; + const repoFileWatcher = new RepoFileWatcher(() => { + if (vsPanel.visible) bridge.post({ command: "refresh" }); + }); + bridge = webviewBridgeFactory(vsPanel.webview, repoFileWatcher); + avatarManager.registerBridge(bridge.post.bind(bridge)); + + const { onPanelShown } = registerMessageHandlers(bridge, { + config, + gitClient, + repoManager, + extensionState, + avatarManager, + repoFileWatcher + }); + + currentPanel = createWebviewPanel({ + panel: vsPanel, + bridge, + config, + repoFileWatcher, + extensionPath: ctx.extensionPath, + extensionState, + avatarManager, + repoManager, + onDispose: () => { + currentPanel = undefined; + }, + onPanelShown + }); + }) + ); +} + +export function initExtension(ctx: vscode.ExtensionContext, repos: string[]) { + const extensionState = new ExtensionState(ctx); + const avatarManager = new AvatarManager(config.gitPath, extensionState); + + ctx.subscriptions.push( + vscode.commands.registerCommand("neo-git-graph.clearAvatarCache", () => { + avatarManager.clearCache(); + }) + ); + + const gitClient = gitClientFactory(extensionState.getLastActiveRepo() ?? "", config.gitPath()); + ctx.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider( + DiffDocProvider.scheme, + new DiffDocProvider(gitClient.getInstance) + ) + ); + + const maxDepth = createMaxDepthTracker(config.maxDepthOfRepoSearch()); + const statusBarItem = new StatusBarItem(ctx, config); + const repoManager = createRepoManager(extensionState, statusBarItem, config); + repoManager.setRepos(repos); + registerViewCommand(ctx, repoManager, extensionState, avatarManager, gitClient); + + const gitWatcher = vscode.workspace.createFileSystemWatcher("**/.git"); + ctx.subscriptions.push( + gitWatcher, + gitWatcher.onDidCreate((uri) => { + const repoPath = path.dirname(uri.fsPath); + if (repoManager.addRepo(repoPath)) repoManager.sendRepos(); + }), + gitWatcher.onDidDelete((uri) => { + const repoPath = path.dirname(uri.fsPath); + if (repoManager.removeReposWithinFolder(repoPath)) repoManager.sendRepos(); + }), + vscode.workspace.onDidChangeWorkspaceFolders(async (e) => { + if (e.added.length > 0) { + const paths = e.added.map((f) => f.uri.fsPath); + const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); + for (const repo of repoDirs) repoManager.addRepo(repo); + if (repoDirs.length > 0) repoManager.sendRepos(); + } + if (e.removed.length > 0) { + let changes = false; + for (const folder of e.removed) { + if (repoManager.removeReposWithinFolder(folder.uri.fsPath)) changes = true; + } + if (changes) repoManager.sendRepos(); + } + }), + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("neo-git-graph.showStatusBarItem")) { + statusBarItem.refresh(); + } else if (e.affectsConfiguration("git.path")) { + gitClient.setGitPath(config.gitPath()); + } else if (e.affectsConfiguration("neo-git-graph.maxDepthOfRepoSearch")) { + if (maxDepth.increased(config.maxDepthOfRepoSearch())) { + const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + void findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()).then( + (repoDirs) => { + if (repoDirs.length > 0) { + repoManager.setRepos(repoDirs); + repoManager.sendRepos(); + } + } + ); + } + } + }) + ); +} diff --git a/src/extension/main.ts b/src/extension/main.ts new file mode 100644 index 0000000..6dfc15d --- /dev/null +++ b/src/extension/main.ts @@ -0,0 +1,23 @@ +import * as vscode from "vscode"; + +import { findGitRepos } from "@/backend/queries/repoSearch"; +import { config } from "@/config"; +import { initExtension } from "@/extension/initExtension"; +import { watchForRepos } from "@/extension/watchForRepos"; +import * as l10n from "@/l10n"; + +export async function activate(ctx: vscode.ExtensionContext) { + l10n.initL10n(ctx.extensionUri.fsPath); + + const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); + + if (repoDirs.length > 0) { + initExtension(ctx, repoDirs); + return; + } + + ctx.subscriptions.push(watchForRepos(ctx, initExtension)); +} + +export function deactivate() {} diff --git a/src/extension/maxDepthTracker.ts b/src/extension/maxDepthTracker.ts new file mode 100644 index 0000000..cc3fa31 --- /dev/null +++ b/src/extension/maxDepthTracker.ts @@ -0,0 +1,10 @@ +export function createMaxDepthTracker(initialDepth: number) { + let current = initialDepth; + return { + increased: (newDepth: number) => { + const prev = current; + current = newDepth; + return current > prev; + } + }; +} diff --git a/src/extension/repoManager.ts b/src/extension/repoManager.ts index 005cc61..3edaf83 100644 --- a/src/extension/repoManager.ts +++ b/src/extension/repoManager.ts @@ -25,6 +25,16 @@ export function createRepoManager( let repos = extensionState.getRepos(); let viewCallback: ((repos: GitRepoSet, numRepos: number) => void) | null = null; + function setRepos(repoDirs: string[]) { + for (const key of Object.keys(repos)) { + delete repos[key]; + } + for (const repo of repoDirs) { + repos[repo] = { columnWidths: null }; + } + extensionState.saveRepos(repos); + } + function getRepos() { return sortRepos(repos); } @@ -58,8 +68,10 @@ export function createRepoManager( } function addRepo(repo: string) { + if (repos[repo]) return false; repos[repo] = { columnWidths: null }; extensionState.saveRepos(repos); + return true; } function removeReposWithinFolder(path: string) { @@ -123,9 +135,10 @@ export function createRepoManager( return { registerViewCallback, deregisterViewCallback, - getRepos, isDirectoryWithinRepos, + getRepos, sendRepos, + setRepos, addRepo, removeRepo, removeReposWithinFolder, diff --git a/src/extension/watchForRepos.ts b/src/extension/watchForRepos.ts new file mode 100644 index 0000000..b9615d1 --- /dev/null +++ b/src/extension/watchForRepos.ts @@ -0,0 +1,66 @@ +import * as vscode from "vscode"; + +import { findGitRepos } from "@/backend/queries/repoSearch"; +import { config } from "@/config"; +import type { InitExtension } from "@/extension/initExtension"; +import { createMaxDepthTracker } from "@/extension/maxDepthTracker"; +import * as l10n from "@/l10n"; + +type WatcherState = { + disposed: boolean; + disposables: vscode.Disposable[]; +}; + +function dispose(state: WatcherState) { + state.disposed = true; + for (const d of state.disposables) d.dispose(); + state.disposables.length = 0; +} + +async function check( + ctx: vscode.ExtensionContext, + state: WatcherState, + onReposFound: InitExtension +) { + if (state.disposed) return; + const paths = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + const repoDirs = await findGitRepos(paths, config.gitPath(), config.maxDepthOfRepoSearch()); + if (repoDirs.length === 0 || state.disposed) return; + dispose(state); + onReposFound(ctx, repoDirs); +} + +export function watchForRepos( + ctx: vscode.ExtensionContext, + onReposFound: InitExtension +): { dispose(): void } { + const maxDepth = createMaxDepthTracker(config.maxDepthOfRepoSearch()); + const gitWatcher = vscode.workspace.createFileSystemWatcher("**/.git"); + const state: WatcherState = { disposed: false, disposables: [gitWatcher] }; + + state.disposables.push( + gitWatcher.onDidCreate(() => check(ctx, state, onReposFound)), + vscode.workspace.onDidChangeWorkspaceFolders(() => check(ctx, state, onReposFound)), + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("neo-git-graph.maxDepthOfRepoSearch")) { + if (maxDepth.increased(config.maxDepthOfRepoSearch())) { + void check(ctx, state, onReposFound); + } + } + }), + vscode.commands.registerCommand("neo-git-graph.view", async () => { + await vscode.window.showErrorMessage(l10n.t("statusBar.text"), { + detail: l10n.t("error.noGitRepository"), + modal: true + }); + }), + vscode.commands.registerCommand("neo-git-graph.clearAvatarCache", async () => { + await vscode.window.showErrorMessage(l10n.t("statusBar.text"), { + detail: l10n.t("error.noGitRepository"), + modal: true + }); + }) + ); + + return { dispose: () => dispose(state) }; +} diff --git a/tests/extension/extension.test.ts b/tests-ext/extension.test.ts similarity index 100% rename from tests/extension/extension.test.ts rename to tests-ext/extension.test.ts diff --git a/tests/extension/repoManager.test.ts b/tests-ext/repoManager.test.ts similarity index 100% rename from tests/extension/repoManager.test.ts rename to tests-ext/repoManager.test.ts diff --git a/tests/extension/tsconfig.json b/tests-ext/tsconfig.json similarity index 71% rename from tests/extension/tsconfig.json rename to tests-ext/tsconfig.json index 011d504..d062d39 100644 --- a/tests/extension/tsconfig.json +++ b/tests-ext/tsconfig.json @@ -1,10 +1,10 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "noEmit": false, - "module": "commonjs", + "module": "CommonJS", "moduleResolution": "node", - "outDir": "../out/extension" + "noEmit": false, + "outDir": "./out" }, "include": ["./**/*.ts"] } diff --git a/tests/extension/workspaceSearch.test.ts b/tests-ext/workspaceSearch.test.ts similarity index 100% rename from tests/extension/workspaceSearch.test.ts rename to tests-ext/workspaceSearch.test.ts diff --git a/tests/extension/workspaceWatcher.test.ts b/tests-ext/workspaceWatcher.test.ts similarity index 100% rename from tests/extension/workspaceWatcher.test.ts rename to tests-ext/workspaceWatcher.test.ts diff --git a/tests/backend/queries/repoSearch.test.ts b/tests/backend/queries/repoSearch.test.ts new file mode 100644 index 0000000..221294e --- /dev/null +++ b/tests/backend/queries/repoSearch.test.ts @@ -0,0 +1,91 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { findGitRepos } from "@/backend/queries/repoSearch"; + +import { git } from "@tests/backend/helpers"; + +// Directory layout created in beforeAll: +// tmpDir/ +// repo-a/ ← git repo +// not-a-repo/ ← plain directory +// nested/ +// repo-b/ ← git repo (depth 2 from tmpDir) + +let tmpDir: string; +let repoA: string; +let repoB: string; + +function initRepo(dir: string) { + fs.mkdirSync(dir, { recursive: true }); + try { + git(["init", "-b", "main"], dir); + } catch { + git(["init"], dir); + git(["checkout", "-b", "main"], dir); + } + git(["config", "user.email", "t@t.com"], dir); + git(["config", "user.name", "T"], dir); + git(["config", "commit.gpgsign", "false"], dir); + fs.writeFileSync(path.join(dir, "f"), "x"); + git(["add", "."], dir); + git(["commit", "-m", "init"], dir); +} + +beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ngg-search-")); + repoA = path.join(tmpDir, "repo-a"); + repoB = path.join(tmpDir, "nested", "repo-b"); + + initRepo(repoA); + initRepo(repoB); + fs.mkdirSync(path.join(tmpDir, "plain")); +}); + +afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("findGitRepos", () => { + it("returns [] when paths is empty", async () => { + expect(await findGitRepos([], "git", 2)).toEqual([]); + }); + + it("finds a repo when the path itself is a git repo", async () => { + expect(await findGitRepos([repoA], "git", 0)).toEqual([repoA]); + }); + + it("returns [] when path is not a repo and maxDepth is 0", async () => { + expect(await findGitRepos([path.join(tmpDir, "plain")], "git", 0)).toEqual([]); + }); + + it("returns [] for a non-existent path", async () => { + expect(await findGitRepos(["/tmp/ngg-does-not-exist-xyz"], "git", 2)).toEqual([]); + }); + + it("finds a repo nested at depth 1", async () => { + expect(await findGitRepos([tmpDir], "git", 1)).toContain(repoA); + }); + + it("does not find a repo beyond maxDepth", async () => { + expect(await findGitRepos([tmpDir], "git", 1)).not.toContain(repoB); + }); + + it("finds a repo exactly at maxDepth", async () => { + expect(await findGitRepos([tmpDir], "git", 2)).toContain(repoB); + }); + + it("aggregates repos across multiple workspace paths", async () => { + const nestedDir = path.join(tmpDir, "nested"); + const result = await findGitRepos([repoA, nestedDir], "git", 1); + expect(result.toSorted()).toEqual([repoA, repoB].toSorted()); + }); + + it("does not report .git directories as repos", async () => { + const result = await findGitRepos([tmpDir], "git", 2); + expect(result.every((r) => !r.endsWith("/.git"))).toBe(true); + }); +}); diff --git a/tests/backend/utils/repoSearch.test.ts b/tests/backend/utils/repoSearch.test.ts index 8fe90f8..b364db8 100644 --- a/tests/backend/utils/repoSearch.test.ts +++ b/tests/backend/utils/repoSearch.test.ts @@ -46,6 +46,7 @@ beforeAll(() => { initRepo(repoB); fs.mkdirSync(nonRepoDir); fs.writeFileSync(path.join(nonRepoDir, "readme.txt"), "hello"); + fs.mkdirSync(path.join(tmpDir, "plain")); }); afterAll(() => { diff --git a/tests/extension/__mocks__/vscode.ts b/tests/extension/__mocks__/vscode.ts new file mode 100644 index 0000000..c6a64d2 --- /dev/null +++ b/tests/extension/__mocks__/vscode.ts @@ -0,0 +1,18 @@ +export const workspace = { + getConfiguration: () => ({ get: (_key: string, def: unknown) => def }), + workspaceFolders: undefined, + createFileSystemWatcher: () => ({ + onDidCreate: () => ({ dispose: () => {} }), + dispose: () => {} + }), + onDidChangeWorkspaceFolders: () => ({ dispose: () => {} }), + onDidChangeConfiguration: () => ({ dispose: () => {} }) +}; + +export const commands = { + registerCommand: () => ({ dispose: () => {} }) +}; + +export const window = { + showErrorMessage: () => Promise.resolve(undefined) +}; diff --git a/tests/extension/watchForRepos.test.ts b/tests/extension/watchForRepos.test.ts new file mode 100644 index 0000000..33b94a5 --- /dev/null +++ b/tests/extension/watchForRepos.test.ts @@ -0,0 +1,234 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { InitExtension } from "@/extension/initExtension"; +import { watchForRepos } from "@/extension/watchForRepos"; + +import { makeRepo } from "@tests/backend/helpers"; + +// ─── controllable vscode mock ───────────────────────────────────────────────── +// +// vi.hoisted runs before any imports so the factory below can safely close over +// these variables when vi.mock("vscode") is evaluated. + +const mock = vi.hoisted(() => { + let folders: Array<{ uri: { fsPath: string } }> = []; + let maxDepthVal = 0; + + let onCreateCb: (() => void) | undefined; + let onFolderChangeCb: (() => void) | undefined; + let onConfigChangeCb: ((e: { affectsConfiguration(k: string): boolean }) => void) | undefined; + const commands: Record Promise> = {}; + const showErrorMessage = vi.fn(); + + return { + workspace: { + get workspaceFolders() { + return folders; + }, + getConfiguration: (section: string) => ({ + get: (key: string, def: unknown) => { + if (section === "neo-git-graph" && key === "maxDepthOfRepoSearch") return maxDepthVal; + if (section === "git" && key === "path") return null; // falls back to "git" + return def; + } + }), + createFileSystemWatcher: () => ({ + onDidCreate: (cb: () => void) => { + onCreateCb = cb; + return { dispose: vi.fn() }; + }, + dispose: vi.fn() + }), + onDidChangeWorkspaceFolders: (cb: () => void) => { + onFolderChangeCb = cb; + return { dispose: vi.fn() }; + }, + onDidChangeConfiguration: (cb: (e: { affectsConfiguration(k: string): boolean }) => void) => { + onConfigChangeCb = cb; + return { dispose: vi.fn() }; + } + }, + commands: { + registerCommand: (name: string, handler: () => Promise) => { + commands[name] = handler; + return { dispose: vi.fn() }; + } + }, + window: { showErrorMessage }, + l10n: { t: (key: string) => key, uri: undefined }, + + // ── test controls ──────────────────────────────────────────────────────── + setFolders(paths: string[]) { + folders = paths.map((p) => ({ uri: { fsPath: p } })); + }, + setMaxDepth(d: number) { + maxDepthVal = d; + }, + fireCreate() { + onCreateCb?.(); + }, + fireFolderChange() { + onFolderChangeCb?.(); + }, + fireConfigChange(key: string) { + onConfigChangeCb?.({ affectsConfiguration: (k) => k === key }); + }, + async invokeCommand(name: string) { + await commands[name]?.(); + }, + showErrorMessage + }; +}); + +vi.mock("vscode", () => mock); + +// ─── shared fixtures ────────────────────────────────────────────────────────── + +// ctx is never inspected by watchForRepos itself — it's just forwarded to onReposFound. +const ctx = {} as unknown as import("vscode").ExtensionContext; + +// Allow real-fs async paths (findGitRepos) to complete. +const tick = (ms = 150) => new Promise((r) => setTimeout(r, ms)); + +let repoDir: string; +let plainDir: string; + +beforeAll(() => { + repoDir = makeRepo(); + plainDir = fs.mkdtempSync(path.join(os.tmpdir(), "ngg-watch-plain-")); +}); + +afterAll(() => { + fs.rmSync(repoDir, { recursive: true, force: true }); + fs.rmSync(plainDir, { recursive: true, force: true }); +}); + +let watcher: ReturnType | undefined; +let onReposFound: ReturnType>; + +beforeEach(() => { + vi.clearAllMocks(); + mock.setFolders([]); + mock.setMaxDepth(0); + onReposFound = vi.fn(); + watcher = undefined; +}); + +afterEach(() => { + watcher?.dispose(); +}); + +// ─── tests ──────────────────────────────────────────────────────────────────── + +describe("watchForRepos", () => { + describe(".git creation trigger", () => { + it("calls onReposFound with found repos", async () => { + mock.setFolders([repoDir]); + watcher = watchForRepos(ctx, onReposFound); + + mock.fireCreate(); + + await vi.waitFor(() => expect(onReposFound).toHaveBeenCalledOnce()); + expect(onReposFound).toHaveBeenCalledWith(ctx, expect.arrayContaining([repoDir])); + }); + + it("does not call onReposFound when no repos are found", async () => { + mock.setFolders([plainDir]); + watcher = watchForRepos(ctx, onReposFound); + + mock.fireCreate(); + await tick(); + + expect(onReposFound).not.toHaveBeenCalled(); + }); + }); + + describe("workspace folders change trigger", () => { + it("calls onReposFound with found repos", async () => { + mock.setFolders([repoDir]); + watcher = watchForRepos(ctx, onReposFound); + + mock.fireFolderChange(); + + await vi.waitFor(() => expect(onReposFound).toHaveBeenCalledOnce()); + expect(onReposFound).toHaveBeenCalledWith(ctx, expect.arrayContaining([repoDir])); + }); + }); + + describe("config change trigger", () => { + it("does not call onReposFound when maxDepth did not increase", () => { + mock.setFolders([repoDir]); + mock.setMaxDepth(0); // same as the tracker's current value → maxDepthIncreased() = false + watcher = watchForRepos(ctx, onReposFound); + + mock.fireConfigChange("neo-git-graph.maxDepthOfRepoSearch"); + + expect(onReposFound).not.toHaveBeenCalled(); + }); + + it("calls onReposFound when maxDepth increases", async () => { + mock.setFolders([repoDir]); + watcher = watchForRepos(ctx, onReposFound); // tracker initializes at 0 + mock.setMaxDepth(5); // increase after tracker is created + + mock.fireConfigChange("neo-git-graph.maxDepthOfRepoSearch"); + + await vi.waitFor(() => expect(onReposFound).toHaveBeenCalledOnce()); + }); + + it("does not call onReposFound for an unrelated config key", () => { + mock.setFolders([repoDir]); + watcher = watchForRepos(ctx, onReposFound); + + mock.fireConfigChange("neo-git-graph.graphStyle"); + + expect(onReposFound).not.toHaveBeenCalled(); + }); + }); + + describe("one-shot disposal", () => { + it("ignores further triggers after onReposFound has been called once", async () => { + mock.setFolders([repoDir]); + watcher = watchForRepos(ctx, onReposFound); + + mock.fireCreate(); + await vi.waitFor(() => expect(onReposFound).toHaveBeenCalledOnce()); + + // State is now disposed — check() exits synchronously before any await. + mock.fireCreate(); + await Promise.resolve(); + + expect(onReposFound).toHaveBeenCalledOnce(); + }); + }); + + describe("error commands (shown before any repo is found)", () => { + it("neo-git-graph.view shows a modal error", async () => { + watcher = watchForRepos(ctx, onReposFound); + + await mock.invokeCommand("neo-git-graph.view"); + + expect(mock.showErrorMessage).toHaveBeenCalledOnce(); + expect(mock.showErrorMessage).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ modal: true }) + ); + }); + + it("neo-git-graph.clearAvatarCache shows a modal error", async () => { + watcher = watchForRepos(ctx, onReposFound); + + await mock.invokeCommand("neo-git-graph.clearAvatarCache"); + + expect(mock.showErrorMessage).toHaveBeenCalledOnce(); + expect(mock.showErrorMessage).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ modal: true }) + ); + }); + }); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index e90eb0c..c3e547b 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -8,6 +8,7 @@ "./extension/**/*.ts", "./webview/**/*.ts", "../src/backend/**/*.ts", + "../src/extension/**/*.ts", "../src/webview/**/*.ts" ] } diff --git a/vitest.config.ts b/vitest.config.ts index 6e5bfe0..b3162da 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,6 +17,21 @@ export default defineConfig({ include: ["tests/backend/**/*.test.ts"] } }, + { + resolve: { + alias: [ + ...alias, + { + find: "vscode", + replacement: path.resolve(__dirname, "tests/extension/__mocks__/vscode.ts") + } + ] + }, + test: { + name: "extension", + include: ["tests/extension/**/*.test.ts"] + } + }, { resolve: { alias: [