Extract view open close controller

This commit is contained in:
murashit 2026-05-29 06:39:07 +09:00
parent a7d68ea27e
commit 3fc2fc2e08
3 changed files with 239 additions and 36 deletions

View file

@ -0,0 +1,70 @@
import type { EventRef, WorkspaceLeaf } from "obsidian";
import { unmountReactRoot } from "../../shared/ui/react-root";
import { unmountChatPanelShell } from "./ui/shell";
export interface ChatViewOpenCloseControllerHost {
setOpened: (opened: boolean) => void;
setClosing: (closing: boolean) => void;
registerEvent: (eventRef: EventRef) => void;
registerComposerNoteIndexInvalidation: (register: (eventRef: EventRef) => void) => void;
registerPointerDown: (handler: (event: PointerEvent) => void) => void;
registerActiveLeafChange: (handler: (leaf: WorkspaceLeaf | null) => void) => void;
isOwnLeaf: (leaf: WorkspaceLeaf | null) => boolean;
scrollMessagesToBottomOnFocus: () => void;
applyCachedSharedAppServerState: () => void;
render: () => void;
scheduleDeferredAppServerWarmup: () => void;
scheduleDeferredRestoredThreadHydration: () => void;
closeToolbarPanelOnOutsidePointer: (event: PointerEvent) => void;
invalidateConnectionWork: () => void;
invalidateResumeWork: () => void;
clearDeferredTasks: () => void;
panelRoot: () => HTMLElement | null;
disposeMessages: () => void;
disposeComposer: () => void;
disconnect: () => void;
clearClient: () => void;
refreshLiveState: () => void;
deferRefreshLiveState: () => void;
}
export class ChatViewOpenCloseController {
constructor(private readonly host: ChatViewOpenCloseControllerHost) {}
open(): void {
this.host.setOpened(true);
this.host.setClosing(false);
this.host.registerComposerNoteIndexInvalidation((eventRef) => {
this.host.registerEvent(eventRef);
});
this.host.registerPointerDown((event) => {
this.host.closeToolbarPanelOnOutsidePointer(event);
});
this.host.registerActiveLeafChange((leaf) => {
if (this.host.isOwnLeaf(leaf)) this.host.scrollMessagesToBottomOnFocus();
});
this.host.applyCachedSharedAppServerState();
this.host.render();
this.host.scheduleDeferredAppServerWarmup();
this.host.scheduleDeferredRestoredThreadHydration();
}
close(): void {
this.host.setOpened(false);
this.host.setClosing(true);
this.host.invalidateConnectionWork();
this.host.invalidateResumeWork();
this.host.clearDeferredTasks();
const panelRoot = this.host.panelRoot();
unmountReactRoot(panelRoot?.querySelector<HTMLElement>(".codex-panel__toolbar") ?? null);
this.host.disposeMessages();
this.host.disposeComposer();
unmountReactRoot(panelRoot?.querySelector<HTMLElement>(".codex-panel__slot--composer") ?? null);
unmountChatPanelShell(panelRoot);
this.host.disconnect();
this.host.clearClient();
this.host.refreshLiveState();
this.host.deferRefreshLiveState();
}
}

View file

@ -19,7 +19,6 @@ import type { CodexPanelSettings } from "../../settings/model";
import { ChatComposerController } from "./chat-composer-controller";
import { activeTurnId, chatTurnBusy, createChatStateStore, type ChatState, type ChatAction } from "./chat-state";
import { renderToolbar, type ToolbarViewModel } from "./ui/toolbar";
import { unmountChatPanelShell } from "./ui/shell";
import type { ChatTurnDiffViewState } from "./ui/turn-diff";
import { ChatMessageRenderer } from "./chat-message-renderer";
import type { OpenCodexPanelSnapshot } from "../../runtime/open-panel-snapshot";
@ -27,7 +26,6 @@ import type { SharedAppServerMetadata } from "../../runtime/shared-app-server-st
import { ChatThreadActionController } from "./thread-actions";
import { ChatRuntimeSettingsController } from "./runtime-settings-controller";
import { RestoredThreadController } from "./restored-thread-controller";
import { unmountReactRoot } from "../../shared/ui/react-root";
import {
activeComposerThreadName as buildActiveComposerThreadName,
activeThreadTitle as buildActiveThreadTitle,
@ -59,6 +57,7 @@ import { ChatConnectionController } from "./connection-controller";
import { ThreadIdentityController } from "./thread-identity-controller";
import { ThreadResumeController } from "./thread-resume-controller";
import { ChatViewRenderController } from "./view-render-controller";
import { ChatViewOpenCloseController } from "./view-open-close-controller";
export interface CodexChatHost {
readonly settings: CodexPanelSettings;
@ -99,6 +98,7 @@ export class CodexChatView extends ItemView {
private readonly composerController: ChatComposerController;
private readonly messageRenderer: ChatMessageRenderer;
private readonly renderController: ChatViewRenderController;
private readonly openCloseController: ChatViewOpenCloseController;
private readonly messageScroll: ChatMessageScrollController;
private readonly turnSubmission: TurnSubmissionController;
private readonly slashCommands: SlashCommandController;
@ -277,6 +277,75 @@ export class CodexChatView extends ItemView {
this.render();
},
});
this.openCloseController = new ChatViewOpenCloseController({
setOpened: (opened) => {
this.opened = opened;
},
setClosing: (closing) => {
this.closing = closing;
},
registerEvent: (eventRef) => {
this.registerEvent(eventRef);
},
registerComposerNoteIndexInvalidation: (register) => {
this.composerController.registerNoteIndexInvalidation(register);
},
registerPointerDown: (handler) => {
this.registerDomEvent(this.containerEl.doc, "pointerdown", handler);
},
registerActiveLeafChange: (handler) => {
this.registerEvent(this.app.workspace.on("active-leaf-change", handler));
},
isOwnLeaf: (leaf) => leaf === this.leaf,
scrollMessagesToBottomOnFocus: () => {
this.messageScroll.scrollToBottomOnFocus();
},
applyCachedSharedAppServerState: () => {
this.applyCachedSharedAppServerState();
},
render: () => {
this.render();
},
scheduleDeferredAppServerWarmup: () => {
this.scheduleDeferredAppServerWarmup();
},
scheduleDeferredRestoredThreadHydration: () => {
this.scheduleDeferredRestoredThreadHydration();
},
closeToolbarPanelOnOutsidePointer: (event) => {
this.closeToolbarPanelOnOutsidePointer(event);
},
invalidateConnectionWork: () => {
this.invalidateConnectionWork();
},
invalidateResumeWork: () => {
this.invalidateResumeWork();
},
clearDeferredTasks: () => {
this.deferredTasks.clearAll();
},
panelRoot: () => this.panelRoot(),
disposeMessages: () => {
this.messageRenderer.dispose();
},
disposeComposer: () => {
this.composerController.dispose();
},
disconnect: () => {
this.connection.disconnect();
},
clearClient: () => {
this.client = null;
},
refreshLiveState: () => {
this.plugin.refreshThreadsViewLiveState();
},
deferRefreshLiveState: () => {
this.containerEl.win.setTimeout(() => {
this.plugin.refreshThreadsViewLiveState();
}, 0);
},
});
this.controller = new ChatController(this.chatState, {
refreshThreads: () => {
void this.refreshThreads();
@ -665,43 +734,11 @@ export class CodexChatView extends ItemView {
}
override async onOpen(): Promise<void> {
this.opened = true;
this.closing = false;
this.composerController.registerNoteIndexInvalidation((eventRef) => {
this.registerEvent(eventRef);
});
this.registerDomEvent(this.containerEl.doc, "pointerdown", (event) => {
this.closeToolbarPanelOnOutsidePointer(event);
});
this.registerEvent(
this.app.workspace.on("active-leaf-change", (leaf) => {
if (leaf === this.leaf) this.messageScroll.scrollToBottomOnFocus();
}),
);
this.applyCachedSharedAppServerState();
this.render();
this.scheduleDeferredAppServerWarmup();
this.scheduleDeferredRestoredThreadHydration();
this.openCloseController.open();
}
override async onClose(): Promise<void> {
this.opened = false;
this.closing = true;
this.invalidateConnectionWork();
this.invalidateResumeWork();
this.deferredTasks.clearAll();
const panelRoot = this.panelRoot();
unmountReactRoot(panelRoot?.querySelector<HTMLElement>(".codex-panel__toolbar") ?? null);
this.messageRenderer.dispose();
this.composerController.dispose();
unmountReactRoot(panelRoot?.querySelector<HTMLElement>(".codex-panel__slot--composer") ?? null);
unmountChatPanelShell(panelRoot);
this.connection.disconnect();
this.client = null;
this.plugin.refreshThreadsViewLiveState();
this.containerEl.win.setTimeout(() => {
this.plugin.refreshThreadsViewLiveState();
}, 0);
this.openCloseController.close();
}
setComposerText(text: string): void {

View file

@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { EventRef } from "obsidian";
import { ChatViewOpenCloseController, type ChatViewOpenCloseControllerHost } from "../../../src/features/chat/view-open-close-controller";
import { unmountChatPanelShell } from "../../../src/features/chat/ui/shell";
import { unmountReactRoot } from "../../../src/shared/ui/react-root";
vi.mock("../../../src/features/chat/ui/shell", () => ({
unmountChatPanelShell: vi.fn(),
}));
vi.mock("../../../src/shared/ui/react-root", () => ({
unmountReactRoot: vi.fn(),
}));
function createHost(overrides: Partial<ChatViewOpenCloseControllerHost> = {}) {
const root = document.createElement("div");
const toolbar = document.createElement("div");
toolbar.className = "codex-panel__toolbar";
root.appendChild(toolbar);
const composer = document.createElement("div");
composer.className = "codex-panel__slot--composer";
root.appendChild(composer);
const host: ChatViewOpenCloseControllerHost = {
setOpened: vi.fn(),
setClosing: vi.fn(),
registerEvent: vi.fn(),
registerComposerNoteIndexInvalidation: vi.fn((register) => {
register({} as EventRef);
}),
registerPointerDown: vi.fn(),
registerActiveLeafChange: vi.fn(),
isOwnLeaf: vi.fn(() => false),
scrollMessagesToBottomOnFocus: vi.fn(),
applyCachedSharedAppServerState: vi.fn(),
render: vi.fn(),
scheduleDeferredAppServerWarmup: vi.fn(),
scheduleDeferredRestoredThreadHydration: vi.fn(),
closeToolbarPanelOnOutsidePointer: vi.fn(),
invalidateConnectionWork: vi.fn(),
invalidateResumeWork: vi.fn(),
clearDeferredTasks: vi.fn(),
panelRoot: () => root,
disposeMessages: vi.fn(),
disposeComposer: vi.fn(),
disconnect: vi.fn(),
clearClient: vi.fn(),
refreshLiveState: vi.fn(),
deferRefreshLiveState: vi.fn(),
...overrides,
};
return { controller: new ChatViewOpenCloseController(host), host, root };
}
describe("ChatViewOpenCloseController", () => {
beforeEach(() => {
vi.mocked(unmountChatPanelShell).mockClear();
vi.mocked(unmountReactRoot).mockClear();
});
it("registers open events and schedules startup work", () => {
const { controller, host } = createHost();
controller.open();
expect(host.setOpened).toHaveBeenCalledWith(true);
expect(host.setClosing).toHaveBeenCalledWith(false);
expect(host.registerEvent).toHaveBeenCalledOnce();
expect(host.registerPointerDown).toHaveBeenCalledOnce();
expect(host.registerActiveLeafChange).toHaveBeenCalledOnce();
expect(host.applyCachedSharedAppServerState).toHaveBeenCalledOnce();
expect(host.render).toHaveBeenCalledOnce();
expect(host.scheduleDeferredAppServerWarmup).toHaveBeenCalledOnce();
expect(host.scheduleDeferredRestoredThreadHydration).toHaveBeenCalledOnce();
});
it("disposes mounted resources and refreshes live state on close", () => {
const { controller, host, root } = createHost();
controller.close();
expect(host.setOpened).toHaveBeenCalledWith(false);
expect(host.setClosing).toHaveBeenCalledWith(true);
expect(host.clearDeferredTasks).toHaveBeenCalledOnce();
expect(unmountReactRoot).toHaveBeenCalledWith(root.querySelector(".codex-panel__toolbar"));
expect(host.disposeMessages).toHaveBeenCalledOnce();
expect(host.disposeComposer).toHaveBeenCalledOnce();
expect(unmountChatPanelShell).toHaveBeenCalledWith(root);
expect(host.disconnect).toHaveBeenCalledOnce();
expect(host.clearClient).toHaveBeenCalledOnce();
expect(host.refreshLiveState).toHaveBeenCalledOnce();
expect(host.deferRefreshLiveState).toHaveBeenCalledOnce();
});
});