diff --git a/src/extension/legacy.ts b/src/extension/legacy.ts index 3f92acfe..464b1393 100644 --- a/src/extension/legacy.ts +++ b/src/extension/legacy.ts @@ -6,7 +6,6 @@ import { config } from "@/old-extension/config"; import { DiffDocProvider } from "@/old-extension/diffDocProvider"; import { ExtensionState } from "@/old-extension/extensionState"; import { registerMessageHandlers } from "@/old-extension/messageHandler"; -import { RepoFileWatcher } from "@/old-extension/repoFileWatcher"; import { createRepoManager } from "@/old-extension/repoManager"; import { webviewBridgeFactory } from "@/old-extension/webviewBridge"; import type { WebviewBridge } from "@/old-extension/webviewBridge"; @@ -31,13 +30,7 @@ export function createMessageProtocol(ctx: vscode.ExtensionContext) { attach(panel: vscode.WebviewPanel) { let isPanelVisible = panel.visible; let disposed = false; - let bridge: WebviewBridge; - const repoFileWatcher = new RepoFileWatcher(() => { - if (panel.visible) { - bridge.post({ command: "refresh" }); - } - }); - bridge = webviewBridgeFactory(panel.webview, repoFileWatcher); + const bridge: WebviewBridge = webviewBridgeFactory(panel.webview); avatarManager.registerBridge(bridge.post); const { onPanelShown } = registerMessageHandlers(bridge, { @@ -45,8 +38,7 @@ export function createMessageProtocol(ctx: vscode.ExtensionContext) { gitClient, repoManager, extensionState, - avatarManager, - repoFileWatcher + avatarManager }); const viewStateListener = panel.onDidChangeViewState(() => { if (panel.visible === isPanelVisible) { @@ -55,8 +47,6 @@ export function createMessageProtocol(ctx: vscode.ExtensionContext) { if (panel.visible) { onPanelShown(); bridge.post({ command: "refresh" }); - } else { - repoFileWatcher.stop(); } isPanelVisible = panel.visible; }); @@ -70,7 +60,6 @@ export function createMessageProtocol(ctx: vscode.ExtensionContext) { bridge.dispose(); viewStateListener.dispose(); avatarManager.deregisterBridge(); - repoFileWatcher.stop(); } }; } diff --git a/src/extension/rpc/rpc-notify.ts b/src/extension/rpc/rpc-notify.ts new file mode 100644 index 00000000..b845115d --- /dev/null +++ b/src/extension/rpc/rpc-notify.ts @@ -0,0 +1,38 @@ +import * as vscode from "vscode"; + +import { logger } from "@/old-extension/utils/logger"; +import type { RpcNotification, RpcNotificationMap, RpcNotificationName } from "@/types"; + +let _webview: vscode.Webview | undefined; + +export const rpcNotify = { + async notify( + name: N, + message: RpcNotificationMap[N] + ): Promise { + if (_webview === undefined) { + logger.log(`Skip RPC notification: ${name}; webview is not initialized`); + return; + } + + const payload = { + kind: "rpc.notify", + id: crypto.randomUUID(), + name, + message + } as RpcNotification; + + logger.log(`Send RPC notification: ${name}`); + await _webview.postMessage(payload); + } +}; + +export function initRpcNotify(webview: vscode.Webview): vscode.Disposable { + _webview = webview; + + return new vscode.Disposable(() => { + if (_webview === webview) { + _webview = undefined; + } + }); +} diff --git a/src/extension/util/debounce.ts b/src/extension/util/debounce.ts new file mode 100644 index 00000000..eed6a583 --- /dev/null +++ b/src/extension/util/debounce.ts @@ -0,0 +1,38 @@ +import type * as vscode from "vscode"; + +import { logger } from "@/old-extension/utils/logger"; + +export type FsWatcherEvent = "created" | "deleted"; + +export function createDebouncer() { + const timers = new Map>(); + + return { + debounce( + type: FsWatcherEvent, + uri: vscode.Uri, + callback: (type: FsWatcherEvent, uri: vscode.Uri) => Promise + ): void { + const key = `${type}:${uri.toString()}`; + const timer = timers.get(key); + + if (timer) { + clearTimeout(timer); + } + + timers.set( + key, + setTimeout(() => { + timers.delete(key); + void callback(type, uri).catch((error: unknown) => { + logger.log(`Unable to process repository change: ${String(error)}`); + }); + }, 100) + ); + }, + dispose(): void { + timers.values().forEach(clearTimeout); + timers.clear(); + } + }; +} diff --git a/src/extension/view-command.ts b/src/extension/view-command.ts index f56dce96..81e34060 100644 --- a/src/extension/view-command.ts +++ b/src/extension/view-command.ts @@ -4,7 +4,10 @@ import { extConfig } from "./config"; import { EXTENSION_NAME } from "./constants"; import { createWevbviewHtml } from "./html"; import { createMessageProtocol } from "./legacy"; +import { initRpcNotify } from "./rpc/rpc-notify"; import { createRpcServer } from "./rpc/rpc-server"; +import { watchGitRepo } from "./watchers/git-repo.watcher"; +import { watchGitDir } from "./watchers/git.watcher"; export function createViewCommand(ctx: vscode.ExtensionContext) { let currentPanel: vscode.WebviewPanel | undefined = undefined; @@ -41,12 +44,18 @@ export function createViewCommand(ctx: vscode.ExtensionContext) { const messageProtocolAttachment = messageProtocol.attach(webPanel); const rpcListener = rpcServer.attach(webPanel.webview); + const rpcNotifier = initRpcNotify(webPanel.webview); + const gitDirWatcher = watchGitDir(); + const gitRepoWatcher = watchGitRepo(); webPanel.webview.html = createWevbviewHtml(ctx, webPanel.webview); webPanel.onDidDispose(() => { messageProtocolAttachment.dispose(); rpcListener.dispose(); + rpcNotifier.dispose(); + gitDirWatcher.dispose(); + gitRepoWatcher.dispose(); currentPanel = undefined; }); currentPanel = webPanel; diff --git a/src/extension/watchers/git-repo.watcher.ts b/src/extension/watchers/git-repo.watcher.ts new file mode 100644 index 00000000..c39f5865 --- /dev/null +++ b/src/extension/watchers/git-repo.watcher.ts @@ -0,0 +1,96 @@ +import path from "node:path"; + +import * as vscode from "vscode"; + +import { rpcNotify } from "@/extension/rpc/rpc-notify"; +import { logger } from "@/old-extension/utils/logger"; + +const REFRESH_DELAY = 750; +const GIT_DATA = /^(HEAD|config|index|packed-refs|refs(?:\/.*)?)$/; + +let selectRepo: ((repo: string) => void) | undefined; +let muteDepth = 0; +let resumeAt = 0; + +export function watchGitRepo(): vscode.Disposable { + let repoPath: string | undefined; + let watcher: vscode.FileSystemWatcher | undefined; + let refreshTimer: ReturnType | undefined; + + const stop = () => { + watcher?.dispose(); + watcher = undefined; + if (refreshTimer !== undefined) { + clearTimeout(refreshTimer); + refreshTimer = undefined; + } + }; + + selectRepo = (repo: string) => { + if (repo === repoPath && watcher !== undefined) { + return; + } + + stop(); + repoPath = repo; + watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(repo, "**/*")); + + const refresh = (uri: vscode.Uri) => { + if (muteDepth > 0 || Date.now() < resumeAt) { + return; + } + + const relativePath = path.relative(repo, uri.fsPath).split(path.sep).join("/"); + if ( + relativePath.startsWith("../") || + (relativePath.startsWith(".git/") && !GIT_DATA.test(relativePath.slice(5))) + ) { + return; + } + + logger.log(`Git repository file changed: ${uri.fsPath}`); + if (refreshTimer !== undefined) { + clearTimeout(refreshTimer); + } + refreshTimer = setTimeout(() => { + refreshTimer = undefined; + logger.log(`Git repository changed: ${repo}`); + void rpcNotify.notify("repo.updated", { path: repo }); + }, REFRESH_DELAY); + }; + + watcher.onDidCreate(refresh); + watcher.onDidChange(refresh); + watcher.onDidDelete(refresh); + }; + + return new vscode.Disposable(() => { + selectRepo = undefined; + muteDepth = 0; + resumeAt = 0; + stop(); + }); +} + +export function selectWatchedRepo(repo: string): void { + if (selectRepo === undefined) { + return; + } + + selectRepo(repo); +} + +export function muteGitRepoWatcher(): void { + muteDepth++; +} + +export function unmuteGitRepoWatcher(): void { + if (muteDepth === 0) { + return; + } + + muteDepth--; + if (muteDepth === 0) { + resumeAt = Date.now() + 1500; + } +} diff --git a/src/extension/watchers/git.watcher.ts b/src/extension/watchers/git.watcher.ts new file mode 100644 index 00000000..4420ba97 --- /dev/null +++ b/src/extension/watchers/git.watcher.ts @@ -0,0 +1,37 @@ +import path from "node:path"; + +import * as vscode from "vscode"; + +import { rpcNotify } from "@/extension/rpc/rpc-notify"; +import { createDebouncer, type FsWatcherEvent } from "@/extension/util/debounce"; +import { logger } from "@/old-extension/utils/logger"; + +export function watchGitDir(): vscode.Disposable { + const debouncer = createDebouncer(); + const watcher = vscode.workspace.createFileSystemWatcher("**/.git", false, true, false); + const createListener = watcher.onDidCreate((uri) => + debouncer.debounce("created", uri, processGitDir) + ); + const deleteListener = watcher.onDidDelete((uri) => + debouncer.debounce("deleted", uri, processGitDir) + ); + return vscode.Disposable.from(watcher, createListener, deleteListener, debouncer); +} + +async function processGitDir(type: FsWatcherEvent, uri: vscode.Uri) { + logger.log(`Git directory ${type}: ${uri.fsPath}`); + const repoPath = path.dirname(uri.fsPath); + + if (type === "created") { + await rpcNotify.notify("repo.changed", { + type, + repo: { + name: path.basename(repoPath), + path: repoPath + } + }); + return; + } + + await rpcNotify.notify("repo.changed", { type, path: repoPath }); +} diff --git a/src/main.ts b/src/main.ts index b23bb8ab..7b63b7c0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,7 +8,6 @@ export function activate(ctx: vscode.ExtensionContext) { if (!vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length <= 0) { return; } - logger.init(ctx); const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left); diff --git a/src/old-extension/initExtension.ts b/src/old-extension/initExtension.ts index d44effcf..a940eee6 100644 --- a/src/old-extension/initExtension.ts +++ b/src/old-extension/initExtension.ts @@ -6,6 +6,7 @@ import { gitClientFactory } from "@/backend/gitClient"; import type { GitClient } from "@/backend/gitClient"; import { findGitRepos } from "@/backend/queries/repoSearch"; import { buildExtensionUri } from "@/backend/utils/path"; +import { watchGitRepo } from "@/extension/watchers/git-repo.watcher"; import { AvatarManager } from "@/old-extension/avatarManager"; import { config } from "@/old-extension/config"; import { EXTENSION_NAME } from "@/old-extension/constant/const"; @@ -13,7 +14,6 @@ import { DiffDocProvider } from "@/old-extension/diffDocProvider"; import { ExtensionState } from "@/old-extension/extensionState"; import { createMaxDepthTracker } from "@/old-extension/maxDepthTracker"; import { registerMessageHandlers } from "@/old-extension/messageHandler"; -import { RepoFileWatcher } from "@/old-extension/repoFileWatcher"; import { createRepoManager } from "@/old-extension/repoManager"; import type { RepoManager } from "@/old-extension/repoManager"; import { StatusBarItem } from "@/old-extension/statusBarItem"; @@ -54,13 +54,8 @@ function registerViewCommand( } ); - let bridge!: WebviewBridge; - const repoFileWatcher = new RepoFileWatcher(() => { - if (vsPanel.visible) { - bridge.post({ command: "refresh" }); - } - }); - bridge = webviewBridgeFactory(vsPanel.webview, repoFileWatcher); + const gitRepoWatcher = watchGitRepo(); + const bridge: WebviewBridge = webviewBridgeFactory(vsPanel.webview); avatarManager.registerBridge(bridge.post.bind(bridge)); const { onPanelShown } = registerMessageHandlers(bridge, { @@ -68,20 +63,19 @@ function registerViewCommand( gitClient, repoManager, extensionState, - avatarManager, - repoFileWatcher + avatarManager }); currentPanel = createWebviewPanel({ panel: vsPanel, bridge, config, - repoFileWatcher, extensionPath: ctx.extensionPath, extensionState, avatarManager, repoManager, onDispose: () => { + gitRepoWatcher.dispose(); currentPanel = undefined; }, onPanelShown diff --git a/src/old-extension/messageHandler.ts b/src/old-extension/messageHandler.ts index 2119b1bb..d3bd1d6d 100644 --- a/src/old-extension/messageHandler.ts +++ b/src/old-extension/messageHandler.ts @@ -15,11 +15,11 @@ import { loadBranches } from "@/backend/queries/loadBranches"; import { loadCommits } from "@/backend/queries/loadCommits"; import type { GitFileChangeType } from "@/backend/types"; import { abbrevCommit } from "@/backend/utils/string"; +import { selectWatchedRepo } from "@/extension/watchers/git-repo.watcher"; import { AvatarManager } from "@/old-extension/avatarManager"; import type { Config } from "@/old-extension/config"; import { encodeDiffDocUri } from "@/old-extension/diffDocProvider"; import { ExtensionState } from "@/old-extension/extensionState"; -import { RepoFileWatcher } from "@/old-extension/repoFileWatcher"; import type { RequestMessage, ResponseMessage } from "@/types"; import type { RepoManager } from "./repoManager"; @@ -65,10 +65,9 @@ export function registerMessageHandlers( repoManager: RepoManager; extensionState: ExtensionState; avatarManager: AvatarManager; - repoFileWatcher: RepoFileWatcher; } ) { - const { config, gitClient, repoManager, extensionState, avatarManager, repoFileWatcher } = deps; + const { config, gitClient, repoManager, extensionState, avatarManager } = deps; let currentRepo: string | null = null; @@ -79,7 +78,7 @@ export function registerMessageHandlers( currentRepo = repo; gitClient.setRepo(repo); extensionState.setLastActiveRepo(repo); - repoFileWatcher.start(repo); + selectWatchedRepo(repo); } function registerAction( diff --git a/src/old-extension/repoFileWatcher.ts b/src/old-extension/repoFileWatcher.ts deleted file mode 100644 index 2b601f3b..00000000 --- a/src/old-extension/repoFileWatcher.ts +++ /dev/null @@ -1,76 +0,0 @@ -import * as vscode from "vscode"; - -import { getPathFromUri } from "@/backend/utils/path"; - -const fileChangeRegex = - /(^\.git\/(config|index|HEAD|refs\/stash|refs\/heads\/.*|refs\/remotes\/.*|refs\/tags\/.*)$)|(^(?!\.git).*$)|(^\.git[^/]+$)/; - -export class RepoFileWatcher { - private repo: string | null = null; - private readonly repoChangeCallback: () => void; - private fsWatcher: vscode.FileSystemWatcher | null = null; - private refreshTimeout: NodeJS.Timeout | null = null; - private muteDepth: number = 0; - private resumeAt: number = 0; - - constructor(repoChangeCallback: () => void) { - this.repoChangeCallback = repoChangeCallback; - } - - public start(repo: string) { - if (this.fsWatcher !== null) { - this.stop(); - } - - this.repo = repo; - this.fsWatcher = vscode.workspace.createFileSystemWatcher(repo + "/**"); - this.fsWatcher.onDidCreate((uri) => this.refresh(uri)); - this.fsWatcher.onDidChange((uri) => this.refresh(uri)); - this.fsWatcher.onDidDelete((uri) => this.refresh(uri)); - } - - public stop() { - if (this.fsWatcher !== null) { - this.fsWatcher.dispose(); - this.fsWatcher = null; - } - } - - public mute() { - this.muteDepth++; - } - - public unmute() { - if (this.muteDepth === 0) { - return; - } - - this.muteDepth--; - if (this.muteDepth === 0) { - this.resumeAt = new Date().getTime() + 1500; - } - } - - private async refresh(uri: vscode.Uri) { - if (this.muteDepth > 0) { - return; - } - if ( - !getPathFromUri(uri) - .replace(this.repo + "/", "") - .match(fileChangeRegex) - ) { - return; - } - if (new Date().getTime() < this.resumeAt) { - return; - } - - if (this.refreshTimeout !== null) { - clearTimeout(this.refreshTimeout); - } - this.refreshTimeout = setTimeout(() => { - this.repoChangeCallback(); - }, 750); - } -} diff --git a/src/old-extension/webviewBridge.ts b/src/old-extension/webviewBridge.ts index b57462d4..43cb0196 100644 --- a/src/old-extension/webviewBridge.ts +++ b/src/old-extension/webviewBridge.ts @@ -1,9 +1,9 @@ import * as vscode from "vscode"; -import { RepoFileWatcher } from "@/old-extension/repoFileWatcher"; +import { muteGitRepoWatcher, unmuteGitRepoWatcher } from "@/extension/watchers/git-repo.watcher"; import type { RequestMessage, ResponseMessage } from "@/types"; -export function webviewBridgeFactory(webview: vscode.Webview, repoFileWatcher: RepoFileWatcher) { +export function webviewBridgeFactory(webview: vscode.Webview) { const handlers = new Map void | Promise>(); const listener = webview.onDidReceiveMessage(async (msg: RequestMessage) => { @@ -11,11 +11,11 @@ export function webviewBridgeFactory(webview: vscode.Webview, repoFileWatcher: R if (!handler) { return; } - repoFileWatcher.mute(); + muteGitRepoWatcher(); try { await handler(msg); } finally { - repoFileWatcher.unmute(); + unmuteGitRepoWatcher(); } }); diff --git a/src/old-extension/webviewPanel.ts b/src/old-extension/webviewPanel.ts index f285f3dc..a6cb1224 100644 --- a/src/old-extension/webviewPanel.ts +++ b/src/old-extension/webviewPanel.ts @@ -4,7 +4,6 @@ import { buildExtensionUri } from "@/backend/utils/path"; import { AvatarManager } from "@/old-extension/avatarManager"; import type { Config } from "@/old-extension/config"; import { ExtensionState } from "@/old-extension/extensionState"; -import { RepoFileWatcher } from "@/old-extension/repoFileWatcher"; import type { RepoManager } from "./repoManager"; import type { WebviewBridge } from "./webviewBridge"; @@ -14,7 +13,6 @@ export function createWebviewPanel(opts: { panel: vscode.WebviewPanel; bridge: WebviewBridge; config: Config; - repoFileWatcher: RepoFileWatcher; extensionPath: string; extensionState: ExtensionState; avatarManager: AvatarManager; @@ -26,7 +24,6 @@ export function createWebviewPanel(opts: { panel, bridge, config, - repoFileWatcher, extensionPath, extensionState, avatarManager, @@ -50,7 +47,6 @@ export function createWebviewPanel(opts: { onDispose(); panel.dispose(); avatarManager.deregisterBridge(); - repoFileWatcher.stop(); while (disposables.length) { const x = disposables.pop(); if (x) { @@ -73,8 +69,6 @@ export function createWebviewPanel(opts: { if (panel.visible) { onPanelShown(); bridge.post({ command: "refresh" }); - } else { - repoFileWatcher.stop(); } isPanelVisible = panel.visible; } diff --git a/src/types/git.types.ts b/src/types/git.types.ts index fe01396a..157427d7 100644 --- a/src/types/git.types.ts +++ b/src/types/git.types.ts @@ -2,3 +2,9 @@ export type GitRepo = { name: string; path: string; }; + +export type RepoChange = { type: "created"; repo: GitRepo } | { type: "deleted"; path: string }; + +export type RepoUpdate = { + path: string; +}; diff --git a/src/types/rpc.types.ts b/src/types/rpc.types.ts index 55b5f7d0..13e0333f 100644 --- a/src/types/rpc.types.ts +++ b/src/types/rpc.types.ts @@ -1,5 +1,5 @@ import type { LocalizedStrings } from "@/old-extension/l10n/webviewL10n"; -import type { GitRepo, WebviewConfig } from "@/types"; +import type { GitRepo, RepoChange, RepoUpdate, WebviewConfig } from "@/types"; export type WebviewInitialize = { l10n: LocalizedStrings; @@ -31,6 +31,23 @@ export type RpcMethodMap = { export type RpcMethod = keyof RpcMethodMap; +export type RpcNotificationMap = { + "repo.changed": RepoChange; + "repo.updated": RepoUpdate; +}; + +export type RpcNotificationName = keyof RpcNotificationMap; + +export type RpcNotification = + N extends RpcNotificationName + ? { + kind: "rpc.notify"; + id: string; + name: N; + message: RpcNotificationMap[N]; + } + : never; + export type RpcRequest = M extends RpcMethod ? { kind: "rpc.request"; diff --git a/src/webview/lib/dispatcher.ts b/src/webview/lib/dispatcher.ts index 4de1b581..28624220 100644 --- a/src/webview/lib/dispatcher.ts +++ b/src/webview/lib/dispatcher.ts @@ -1,5 +1,6 @@ import type { ResponseMessage } from "@/types"; import { handleRpcResponse } from "@/webview/lib/rpc/rpc-client"; +import { handleRpcNotification } from "@/webview/lib/rpc/rpc-notify"; import { handleActionResult } from "./handler/action-result"; import { handleCommitDetails } from "./handler/commit-details"; @@ -40,6 +41,9 @@ export function initDispatcher() { if (handleRpcResponse(e.data)) { return; } + if (handleRpcNotification(e.data)) { + return; + } dispatch(e.data as ResponseMessage); }); } diff --git a/src/webview/lib/rpc/rpc-notify.ts b/src/webview/lib/rpc/rpc-notify.ts new file mode 100644 index 00000000..03cca0e4 --- /dev/null +++ b/src/webview/lib/rpc/rpc-notify.ts @@ -0,0 +1,35 @@ +import type { RpcNotification } from "@/types"; +import { refresh } from "@/webview/lib/actions"; +import { selectedRepo } from "@/webview/lib/stores"; +import { repoListStore } from "@/webview/lib/stores/repo-list.store"; + +export function handleRpcNotification(message: unknown): boolean { + if (!isRpcNotification(message)) { + return false; + } + + switch (message.name) { + case "repo.changed": + repoListStore.apply(message.message); + return true; + case "repo.updated": + if (message.message.path === selectedRepo.value) { + refresh(); + } + return true; + } +} + +function isRpcNotification(message: unknown): message is RpcNotification { + return ( + typeof message === "object" && + message !== null && + "kind" in message && + message.kind === "rpc.notify" && + "id" in message && + typeof message.id === "string" && + "name" in message && + (message.name === "repo.changed" || message.name === "repo.updated") && + "message" in message + ); +} diff --git a/src/webview/lib/stores/repo-list.store.ts b/src/webview/lib/stores/repo-list.store.ts index 8eb4ee2d..1fa1cdc2 100644 --- a/src/webview/lib/stores/repo-list.store.ts +++ b/src/webview/lib/stores/repo-list.store.ts @@ -1,6 +1,6 @@ import { signal } from "@preact/signals"; -import type { GitRepo } from "@/types"; +import type { GitRepo, RepoChange } from "@/types"; import { rpc } from "@/webview/lib/rpc/rpc-client"; const repoList = signal | undefined>(undefined); @@ -14,5 +14,18 @@ export const repoListStore = { const result = await rpc.call("repo.scan", null); repoList.value = result.repos; return result.repos; + }, + apply: (change: RepoChange): void => { + const repos = repoList.value ?? []; + + if (change.type === "created") { + repoList.value = [ + ...repos.filter((repo) => repo.path !== change.repo.path), + change.repo + ].toSorted((a, b) => a.path.localeCompare(b.path)); + return; + } + + repoList.value = repos.filter((repo) => repo.path !== change.path); } }; diff --git a/src/webview/main.tsx b/src/webview/main.tsx index 396f5aca..2363b6af 100644 --- a/src/webview/main.tsx +++ b/src/webview/main.tsx @@ -1,20 +1,22 @@ import "./styles.css"; +import { signal } from "@preact/signals"; import { render } from "preact"; - -import type { GitRepo } from "@/types"; +import { useEffect } from "preact/hooks"; import { App } from "./App"; import { Button } from "./components/ui/Button"; +import { selectRepo } from "./lib/actions"; import { initDispatcher } from "./lib/dispatcher"; import { rpc } from "./lib/rpc/rpc-client"; -import { initializeStores } from "./lib/stores"; +import { initializeStores, selectedRepo } from "./lib/stores"; import { repoListStore } from "./lib/stores/repo-list.store"; import { initializeWebviewConfig } from "./lib/webview-config"; import { LoadingPage } from "./pages/LoadingPage"; import { NoRepoPage } from "./pages/NoRepoPage"; const root = document.getElementById("app")!; +const repoListError = signal(undefined); initDispatcher(); render(, root); @@ -34,30 +36,52 @@ async function main() { initializeWebviewConfig(config); initializeStores(config.initialLoadCommits); + render(, root); await loadRepoList(); } async function loadRepoList() { - render(, root); + repoListError.value = undefined; - let repos: Array; try { - repos = await repoListStore.load(); + await repoListStore.load(); } catch (error: unknown) { - render( + repoListError.value = error instanceof Error ? error.message : String(error); + } +} + +function Root() { + const repos = repoListStore.get(); + const error = repoListError.value; + + useEffect(() => { + if (repos === undefined) { + return; + } + + if (repos.length === 0) { + selectedRepo.value = undefined; + return; + } + + const firstRepo = repos[0]; + if (firstRepo !== undefined && !repos.some((repo) => repo.path === selectedRepo.value)) { + selectRepo(firstRepo.path); + } + }, [repos]); + + if (error !== undefined) { + return (
-

Unable to load repositories: {error instanceof Error ? error.message : String(error)}

+

Unable to load repositories: {error}

-
, - root + ); - return; } - if (repos.length === 0) { - render(, root); - return; + if (repos === undefined) { + return ; } - render(, root); + return repos.length === 0 ? : ; } diff --git a/tests/old-extension/repoFileWatcher.test.ts b/tests/old-extension/repoFileWatcher.test.ts deleted file mode 100644 index 7b260734..00000000 --- a/tests/old-extension/repoFileWatcher.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import * as vscode from "vscode"; - -import { RepoFileWatcher } from "@/old-extension/repoFileWatcher"; - -afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); -}); - -describe("RepoFileWatcher", () => { - it("stays muted until all active mutes are removed", () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - let onDidChange: ((uri: vscode.Uri) => void) | undefined; - vi.spyOn(vscode.workspace, "createFileSystemWatcher").mockReturnValue({ - onDidCreate: vi.fn(() => ({ dispose: vi.fn() })), - onDidChange: vi.fn((handler: (uri: vscode.Uri) => void) => { - onDidChange = handler; - return { dispose: vi.fn() }; - }), - onDidDelete: vi.fn(() => ({ dispose: vi.fn() })), - dispose: vi.fn() - } as unknown as vscode.FileSystemWatcher); - const onRepoChange = vi.fn(); - const watcher = new RepoFileWatcher(onRepoChange); - watcher.start("/repo"); - - watcher.mute(); - watcher.mute(); - watcher.unmute(); - onDidChange?.({ fsPath: "/repo/file.txt" } as vscode.Uri); - vi.advanceTimersByTime(750); - expect(onRepoChange).not.toHaveBeenCalled(); - - watcher.unmute(); - vi.advanceTimersByTime(1501); - onDidChange?.({ fsPath: "/repo/file.txt" } as vscode.Uri); - vi.advanceTimersByTime(750); - expect(onRepoChange).toHaveBeenCalledOnce(); - }); -}); diff --git a/tests/old-extension/webviewBridge.test.ts b/tests/old-extension/webviewBridge.test.ts index af080065..8164b1cb 100644 --- a/tests/old-extension/webviewBridge.test.ts +++ b/tests/old-extension/webviewBridge.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { RepoFileWatcher } from "@/old-extension/repoFileWatcher"; import { webviewBridgeFactory } from "@/old-extension/webviewBridge"; import type { RequestMessage } from "@/types"; @@ -14,28 +13,15 @@ function createBridge() { }), postMessage: vi.fn() }; - const repoFileWatcher = { mute: vi.fn(), unmute: vi.fn() }; - const bridge = webviewBridgeFactory( - webview as unknown as import("vscode").Webview, - repoFileWatcher as unknown as RepoFileWatcher - ); + const bridge = webviewBridgeFactory(webview as unknown as import("vscode").Webview); return { bridge, dispose, - repoFileWatcher, receive: (message: RequestMessage) => receiveMessage!(message) }; } -function deferred() { - let resolve!: () => void; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; -} - describe("webviewBridgeFactory", () => { it("disposes its message listener", () => { const { bridge, dispose } = createBridge(); @@ -45,44 +31,13 @@ describe("webviewBridgeFactory", () => { expect(dispose).toHaveBeenCalledOnce(); }); - it("unmutes the repository watcher when a handler rejects", async () => { - const { bridge, repoFileWatcher, receive } = createBridge(); + it("propagates handler errors", async () => { + const { bridge, receive } = createBridge(); const failure = new Error("failed"); bridge.onMessage("selectRepo", async () => { throw failure; }); await expect(receive({ command: "selectRepo", repo: "/repo" })).rejects.toBe(failure); - - expect(repoFileWatcher.mute).toHaveBeenCalledOnce(); - expect(repoFileWatcher.unmute).toHaveBeenCalledOnce(); - }); - - it("keeps one mute active for each concurrent handler", async () => { - const { bridge, repoFileWatcher, receive } = createBridge(); - const first = deferred(); - const second = deferred(); - const handlers = [first, second]; - let nextHandler = 0; - bridge.onMessage("selectRepo", () => { - const handler = handlers[nextHandler++]; - if (handler === undefined) { - throw new Error("Missing deferred handler"); - } - return handler.promise; - }); - - const firstMessage = receive({ command: "selectRepo", repo: "/repo" }); - const secondMessage = receive({ command: "selectRepo", repo: "/repo" }); - expect(repoFileWatcher.mute).toHaveBeenCalledTimes(2); - expect(repoFileWatcher.unmute).not.toHaveBeenCalled(); - - first.resolve(); - await firstMessage; - expect(repoFileWatcher.unmute).toHaveBeenCalledOnce(); - - second.resolve(); - await secondMessage; - expect(repoFileWatcher.unmute).toHaveBeenCalledTimes(2); }); }); diff --git a/tests/old-extension/webviewPanel.test.ts b/tests/old-extension/webviewPanel.test.ts index 052379ea..6c0002e9 100644 --- a/tests/old-extension/webviewPanel.test.ts +++ b/tests/old-extension/webviewPanel.test.ts @@ -32,7 +32,6 @@ describe("createWebviewPanel", () => { dispose: vi.fn() }; const bridge = { post: vi.fn() }; - const repoFileWatcher = { stop: vi.fn() }; const repoManager = { getRepos: vi.fn(() => ({ "/repo": { columnWidths: null } })) }; @@ -44,8 +43,6 @@ describe("createWebviewPanel", () => { config: { tabIconColourTheme: () => "colour" } as unknown as import("@/old-extension/config").Config, - repoFileWatcher: - repoFileWatcher as unknown as import("@/old-extension/repoFileWatcher").RepoFileWatcher, extensionPath: "/extension", extensionState: { getLastActiveRepo: () => "/repo" @@ -63,7 +60,6 @@ describe("createWebviewPanel", () => { panel.visible = false; viewStateHandler?.(); - expect(repoFileWatcher.stop).toHaveBeenCalledTimes(1); panel.visible = true; viewStateHandler?.();