mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Narrow chat controller composition bridges
This commit is contained in:
parent
0d717dd36b
commit
6007fed826
3 changed files with 123 additions and 115 deletions
|
|
@ -22,7 +22,7 @@ import type { ThreadSelectionActions } from "../threads/thread-selection-control
|
|||
import type { ChatViewRenderController, ChatViewSlotRenderers } from "./view-render-controller";
|
||||
import type { ChatMessageRenderer } from "../ui/message-stream";
|
||||
import type { ChatControllerCompositionPorts } from "./controller-ports";
|
||||
import { createChatControllerCompositionActions, requireCompositionRef, type ChatControllerCompositionRefs } from "./controller-wiring";
|
||||
import { createChatControllerCompositionActions, type ChatControllerCompositionBridges } from "./controller-wiring";
|
||||
import {
|
||||
createChatServerActionControllers,
|
||||
createChatConnectionControllers,
|
||||
|
|
@ -84,20 +84,14 @@ export interface ChatViewControllers {
|
|||
export function createChatViewControllers(ports: ChatControllerCompositionPorts): ChatViewControllers {
|
||||
const connection = new ConnectionManager(() => ports.plugin.settings.codexPath, ports.plugin.vaultPath);
|
||||
const { renderController } = createViewRenderControllerGroup(ports, { connection });
|
||||
const refs: ChatControllerCompositionRefs = {
|
||||
renderController,
|
||||
controller: null,
|
||||
connectionController: null,
|
||||
threadSelection: null,
|
||||
threadRename: null,
|
||||
threadResume: null,
|
||||
restoredThread: null,
|
||||
serverMetadata: null,
|
||||
serverDiagnostics: null,
|
||||
messageRenderer: null,
|
||||
composerController: null,
|
||||
const bridges: ChatControllerCompositionBridges = {
|
||||
systemMessages: { controller: null },
|
||||
connection: { controller: null },
|
||||
threadSelection: { actions: null },
|
||||
messageViewport: { renderer: null },
|
||||
composerDraft: { controller: null },
|
||||
};
|
||||
const actions = createChatControllerCompositionActions(ports, refs);
|
||||
const actions = createChatControllerCompositionActions(ports, { renderController, bridges });
|
||||
const runtimeSettings = createChatRuntimeSettingsActions({
|
||||
stateStore: ports.state.stateStore,
|
||||
currentClient: ports.client.getClient,
|
||||
|
|
@ -120,10 +114,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
},
|
||||
);
|
||||
const { history, threadActions, goals, threadIdentity } = threadControllers;
|
||||
refs.restoredThread = threadControllers.restoredThread;
|
||||
refs.threadResume = threadControllers.threadResume;
|
||||
refs.threadRename = threadControllers.threadRename;
|
||||
const threadRename = requireCompositionRef(refs.threadRename, "thread rename controller");
|
||||
const { restoredThread, threadResume, threadRename } = threadControllers;
|
||||
const lifecycleActions = {
|
||||
...ports.lifecycle,
|
||||
invalidateResumeWork: threadControllers.invalidateResumeWork,
|
||||
|
|
@ -133,22 +124,32 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
...ports,
|
||||
lifecycle: lifecycleActions,
|
||||
render: actions.render,
|
||||
thread: actions.thread,
|
||||
thread: {
|
||||
restorePlaceholder: (restoredThreadState) => {
|
||||
restoredThread.restore(restoredThreadState);
|
||||
},
|
||||
clearRestoredLifecycle: () => {
|
||||
restoredThread.clear();
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
threadActions,
|
||||
},
|
||||
);
|
||||
refs.threadSelection = createThreadSelectionControllerGroup(
|
||||
const threadSelection = createThreadSelectionControllerGroup(
|
||||
{
|
||||
...ports,
|
||||
thread: actions.thread,
|
||||
thread: {
|
||||
resumeThread: (threadId) => threadResume.resumeThread(threadId),
|
||||
},
|
||||
status: actions.status,
|
||||
},
|
||||
{
|
||||
toolbarPanels,
|
||||
},
|
||||
).threadSelection;
|
||||
bridges.threadSelection.actions = threadSelection;
|
||||
const { reconnectActions } = createChatReconnectControllerGroup(
|
||||
{
|
||||
...ports,
|
||||
|
|
@ -156,7 +157,9 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
lifecycle: lifecycleActions,
|
||||
render: actions.render,
|
||||
status: actions.status,
|
||||
thread: actions.thread,
|
||||
thread: {
|
||||
resumeThread: (threadId) => threadResume.resumeThread(threadId),
|
||||
},
|
||||
},
|
||||
{
|
||||
connection,
|
||||
|
|
@ -167,16 +170,20 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
goals,
|
||||
});
|
||||
const { serverThreads, serverMetadata, serverDiagnostics } = serverActionControllers;
|
||||
refs.serverMetadata = serverMetadata;
|
||||
refs.serverDiagnostics = serverDiagnostics;
|
||||
const serverRequestHost = {
|
||||
currentClient: ports.client.getClient,
|
||||
};
|
||||
refs.controller = createChatInboundController(
|
||||
const inboundController = createChatInboundController(
|
||||
{
|
||||
...ports,
|
||||
render: actions.render,
|
||||
thread: actions.thread,
|
||||
thread: {
|
||||
refreshThreads: actions.thread.refreshThreads,
|
||||
refreshSkills: actions.thread.refreshSkills,
|
||||
publishAppServerMetadataSnapshot: () => {
|
||||
serverMetadata.publishAppServerMetadataSnapshot();
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
serverMetadata,
|
||||
|
|
@ -186,12 +193,19 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
rejectServerRequest: (requestId, code, message) => rejectServerRequest(serverRequestHost, requestId, code, message),
|
||||
},
|
||||
);
|
||||
refs.connectionController = createChatConnectionControllers(
|
||||
bridges.systemMessages.controller = inboundController;
|
||||
const connectionController = createChatConnectionControllers(
|
||||
{
|
||||
...ports,
|
||||
client: actions.client,
|
||||
lifecycle: lifecycleActions,
|
||||
thread: actions.thread,
|
||||
thread: {
|
||||
loadSharedThreadList: ports.thread.loadSharedThreadList,
|
||||
refreshTabHeader: ports.thread.refreshTabHeader,
|
||||
resetTurnPresence: (hadTurns) => {
|
||||
threadRename.resetThreadTurnPresence(hadTurns);
|
||||
},
|
||||
},
|
||||
status: actions.status,
|
||||
liveState: ports.liveState,
|
||||
render: actions.render,
|
||||
|
|
@ -202,8 +216,7 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
serverDiagnostics,
|
||||
},
|
||||
).connectionController;
|
||||
const inboundController = requireCompositionRef(refs.controller, "inbound controller");
|
||||
const connectionController = requireCompositionRef(refs.connectionController, "connection controller");
|
||||
bridges.connection.controller = connectionController;
|
||||
|
||||
connection.setHandlers({
|
||||
onNotification: (notification) => {
|
||||
|
|
@ -230,8 +243,19 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
...ports,
|
||||
client: actions.client,
|
||||
render: actions.render,
|
||||
runtime: actions.runtime,
|
||||
thread: actions.thread,
|
||||
runtime: {
|
||||
...actions.runtime,
|
||||
mcpStatusLines: () => serverDiagnostics.mcpStatusLines(),
|
||||
},
|
||||
thread: {
|
||||
ensureRestoredThreadLoaded: ports.thread.ensureRestoredThreadLoaded,
|
||||
startNewThread: ports.thread.startNewThread,
|
||||
selectThread: actions.thread.selectThread,
|
||||
notifyIdentityChanged: ports.thread.notifyIdentityChanged,
|
||||
resetTurnPresence: (hadTurns) => {
|
||||
threadRename.resetThreadTurnPresence(hadTurns);
|
||||
},
|
||||
},
|
||||
status: actions.status,
|
||||
scroll: actions.scroll,
|
||||
},
|
||||
|
|
@ -247,10 +271,9 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
},
|
||||
);
|
||||
const { pendingRequests, composerSubmission } = conversationControllers;
|
||||
refs.messageRenderer = conversationControllers.messageRenderer;
|
||||
refs.composerController = conversationControllers.composerController;
|
||||
const messageRenderer = requireCompositionRef(refs.messageRenderer, "message renderer");
|
||||
const composerController = requireCompositionRef(refs.composerController, "composer controller");
|
||||
const { messageRenderer, composerController } = conversationControllers;
|
||||
bridges.messageViewport.renderer = messageRenderer;
|
||||
bridges.composerDraft.controller = composerController;
|
||||
const { scheduleAppServerWarmup, openView, closeView } = createConnectionLifecycleControllerGroup(
|
||||
{
|
||||
...ports,
|
||||
|
|
@ -266,9 +289,6 @@ export function createChatViewControllers(ports: ChatControllerCompositionPorts)
|
|||
serverMetadata,
|
||||
},
|
||||
);
|
||||
const threadResume = requireCompositionRef(refs.threadResume, "thread resume controller");
|
||||
const restoredThread = requireCompositionRef(refs.restoredThread, "restored thread controller");
|
||||
const threadSelection = requireCompositionRef(refs.threadSelection, "thread selection controller");
|
||||
|
||||
return {
|
||||
connection: {
|
||||
|
|
|
|||
|
|
@ -1,28 +1,27 @@
|
|||
import type { ChatServerDiagnosticsActions } from "../server-actions/diagnostics-actions";
|
||||
import type { ChatServerMetadataActions } from "../server-actions/metadata-actions";
|
||||
import type { ChatComposerController } from "../composer/controller";
|
||||
import type { ChatInboundController } from "../inbound/controller";
|
||||
import type { RestoredThreadController } from "../threads/restored-thread-controller";
|
||||
import type { ChatConnectionController } from "../session/connection-controller";
|
||||
import type { ThreadRenameController } from "../threads/thread-rename-controller";
|
||||
import type { ThreadResumeController } from "../threads/thread-resume-controller";
|
||||
import type { ThreadSelectionActions } from "../threads/thread-selection-controller";
|
||||
import type { ChatMessageRenderer } from "../ui/message-stream";
|
||||
import type { ChatControllerCompositionPorts } from "./controller-ports";
|
||||
import type { ChatViewRenderController } from "./view-render-controller";
|
||||
|
||||
export interface ChatControllerCompositionRefs {
|
||||
renderController: ChatViewRenderController;
|
||||
controller: ChatInboundController | null;
|
||||
connectionController: ChatConnectionController | null;
|
||||
threadSelection: ThreadSelectionActions | null;
|
||||
threadRename: ThreadRenameController | null;
|
||||
threadResume: ThreadResumeController | null;
|
||||
restoredThread: RestoredThreadController | null;
|
||||
serverMetadata: ChatServerMetadataActions | null;
|
||||
serverDiagnostics: ChatServerDiagnosticsActions | null;
|
||||
messageRenderer: ChatMessageRenderer | null;
|
||||
composerController: ChatComposerController | null;
|
||||
export interface ChatControllerCompositionBridges {
|
||||
systemMessages: {
|
||||
controller: Pick<ChatInboundController, "addSystemMessage" | "addStructuredSystemMessage"> | null;
|
||||
};
|
||||
connection: {
|
||||
controller: Pick<ChatConnectionController, "ensureConnected" | "refreshThreads" | "refreshSkills"> | null;
|
||||
};
|
||||
threadSelection: {
|
||||
actions: Pick<ThreadSelectionActions, "selectThread"> | null;
|
||||
};
|
||||
messageViewport: {
|
||||
renderer: Pick<ChatMessageRenderer, "forceMessagesToBottom"> | null;
|
||||
};
|
||||
composerDraft: {
|
||||
controller: Pick<ChatComposerController, "setDraft"> | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatControllerCompositionActions {
|
||||
|
|
@ -40,17 +39,10 @@ export interface ChatControllerCompositionActions {
|
|||
scroll: ChatControllerCompositionPorts["scroll"];
|
||||
thread: ChatControllerCompositionPorts["thread"] & {
|
||||
selectThread: (threadId: string) => Promise<void>;
|
||||
resumeThread: (threadId: string) => Promise<void>;
|
||||
refreshThreads: () => Promise<void>;
|
||||
refreshSkills: (forceReload?: boolean) => Promise<void>;
|
||||
publishAppServerMetadataSnapshot: () => void;
|
||||
resetTurnPresence: (hadTurns: boolean) => void;
|
||||
restorePlaceholder: (restoredThreadState: Parameters<RestoredThreadController["restore"]>[0]) => void;
|
||||
clearRestoredLifecycle: () => void;
|
||||
};
|
||||
runtime: ChatControllerCompositionPorts["runtime"] & {
|
||||
mcpStatusLines: () => Promise<string[]>;
|
||||
};
|
||||
runtime: ChatControllerCompositionPorts["runtime"];
|
||||
composer: {
|
||||
setText: (text: string) => void;
|
||||
};
|
||||
|
|
@ -58,57 +50,47 @@ export interface ChatControllerCompositionActions {
|
|||
|
||||
export function createChatControllerCompositionActions(
|
||||
ports: ChatControllerCompositionPorts,
|
||||
refs: ChatControllerCompositionRefs,
|
||||
deps: {
|
||||
renderController: ChatViewRenderController;
|
||||
bridges: ChatControllerCompositionBridges;
|
||||
},
|
||||
): ChatControllerCompositionActions {
|
||||
const { bridges, renderController } = deps;
|
||||
const render = {
|
||||
...ports.render,
|
||||
now: () => {
|
||||
refs.renderController.render();
|
||||
renderController.render();
|
||||
},
|
||||
shellSlots: () => {
|
||||
refs.renderController.renderShellSlots();
|
||||
renderController.renderShellSlots();
|
||||
},
|
||||
};
|
||||
const status = {
|
||||
...ports.status,
|
||||
addSystemMessage: (text: string) => {
|
||||
requireCompositionRef(refs.controller, "inbound controller").addSystemMessage(text);
|
||||
requireCompositionBridge(bridges.systemMessages.controller, "system message bridge").addSystemMessage(text);
|
||||
render.now();
|
||||
},
|
||||
addStructuredSystemMessage: (text: string, details: Parameters<ChatInboundController["addStructuredSystemMessage"]>[1]) => {
|
||||
requireCompositionRef(refs.controller, "inbound controller").addStructuredSystemMessage(text, details);
|
||||
requireCompositionBridge(bridges.systemMessages.controller, "system message bridge").addStructuredSystemMessage(text, details);
|
||||
render.now();
|
||||
},
|
||||
};
|
||||
|
||||
const threadNavigation = {
|
||||
selectThread: (threadId: string) => requireCompositionRef(refs.threadSelection, "thread selection controller").selectThread(threadId),
|
||||
resumeThread: (threadId: string) => requireCompositionRef(refs.threadResume, "thread resume controller").resumeThread(threadId),
|
||||
selectThread: (threadId: string) =>
|
||||
requireCompositionBridge(bridges.threadSelection.actions, "thread selection bridge").selectThread(threadId),
|
||||
};
|
||||
const threadRefresh = {
|
||||
refreshThreads: () => requireCompositionRef(refs.connectionController, "connection controller").refreshThreads(),
|
||||
refreshThreads: () => requireCompositionBridge(bridges.connection.controller, "connection bridge").refreshThreads(),
|
||||
refreshSkills: (forceReload?: boolean) =>
|
||||
requireCompositionRef(refs.connectionController, "connection controller").refreshSkills(forceReload),
|
||||
publishAppServerMetadataSnapshot: () => {
|
||||
requireCompositionRef(refs.serverMetadata, "server metadata actions").publishAppServerMetadataSnapshot();
|
||||
},
|
||||
};
|
||||
const threadLifecycle = {
|
||||
resetTurnPresence: (hadTurns: boolean) => {
|
||||
requireCompositionRef(refs.threadRename, "thread rename controller").resetThreadTurnPresence(hadTurns);
|
||||
},
|
||||
restorePlaceholder: (restoredThreadState: Parameters<RestoredThreadController["restore"]>[0]) => {
|
||||
requireCompositionRef(refs.restoredThread, "restored thread controller").restore(restoredThreadState);
|
||||
},
|
||||
clearRestoredLifecycle: () => {
|
||||
requireCompositionRef(refs.restoredThread, "restored thread controller").clear();
|
||||
},
|
||||
requireCompositionBridge(bridges.connection.controller, "connection bridge").refreshSkills(forceReload),
|
||||
};
|
||||
|
||||
return {
|
||||
client: {
|
||||
...ports.client,
|
||||
ensureConnected: () => requireCompositionRef(refs.connectionController, "connection controller").ensureConnected(),
|
||||
ensureConnected: () => requireCompositionBridge(bridges.connection.controller, "connection bridge").ensureConnected(),
|
||||
},
|
||||
render,
|
||||
status,
|
||||
|
|
@ -116,28 +98,27 @@ export function createChatControllerCompositionActions(
|
|||
...ports.scroll,
|
||||
forceBottom: () => {
|
||||
ports.scroll.forceBottom();
|
||||
requireCompositionRef(refs.messageRenderer, "message renderer").forceMessagesToBottom();
|
||||
requireCompositionBridge(bridges.messageViewport.renderer, "message viewport bridge").forceMessagesToBottom();
|
||||
},
|
||||
},
|
||||
thread: {
|
||||
...ports.thread,
|
||||
...threadNavigation,
|
||||
...threadRefresh,
|
||||
...threadLifecycle,
|
||||
},
|
||||
runtime: {
|
||||
...ports.runtime,
|
||||
mcpStatusLines: () => requireCompositionRef(refs.serverDiagnostics, "server diagnostics actions").mcpStatusLines(),
|
||||
},
|
||||
runtime: ports.runtime,
|
||||
composer: {
|
||||
setText: (text) => {
|
||||
requireCompositionRef(refs.composerController, "composer controller").setDraft(text, { focus: true, renderIfDetached: true });
|
||||
requireCompositionBridge(bridges.composerDraft.controller, "composer draft bridge").setDraft(text, {
|
||||
focus: true,
|
||||
renderIfDetached: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function requireCompositionRef<T>(value: T | null, name: string): T {
|
||||
function requireCompositionBridge<T>(value: T | null, name: string): T {
|
||||
if (!value) throw new Error(`Chat controller composition did not initialize ${name}.`);
|
||||
return value;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,10 +46,8 @@ interface ThreadControllerGroupPorts {
|
|||
};
|
||||
thread: {
|
||||
selectThread: (threadId: string) => Promise<void>;
|
||||
resumeThread: (threadId: string) => Promise<void>;
|
||||
refreshThreads: () => Promise<void>;
|
||||
notifyIdentityChanged: () => void;
|
||||
resetTurnPresence: (hadTurns: boolean) => void;
|
||||
refreshTabHeader: () => void;
|
||||
};
|
||||
status: {
|
||||
|
|
@ -83,13 +81,27 @@ export function createThreadControllerGroup(
|
|||
const currentClient = client.getClient;
|
||||
const { deferredTasks, resumeWork } = lifecycle;
|
||||
|
||||
const threadRename = new ThreadRenameController({
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
settings: () => plugin.settings,
|
||||
ensureConnected: client.ensureConnected,
|
||||
currentClient: () => refs.connection.currentClient(),
|
||||
refreshThreads: thread.refreshThreads,
|
||||
render: render.shellSlots,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
|
||||
});
|
||||
const resetThreadTurnPresence = (hadTurns: boolean) => {
|
||||
threadRename.resetThreadTurnPresence(hadTurns);
|
||||
};
|
||||
const history = new ThreadHistoryController({
|
||||
stateStore,
|
||||
currentClient,
|
||||
render: render.now,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
keepCurrentScrollPosition: scroll.preservePosition,
|
||||
setThreadTurnPresence: thread.resetTurnPresence,
|
||||
setThreadTurnPresence: resetThreadTurnPresence,
|
||||
});
|
||||
const invalidateResumeWork = () => {
|
||||
resumeWork.invalidate();
|
||||
|
|
@ -129,17 +141,18 @@ export function createThreadControllerGroup(
|
|||
render: render.now,
|
||||
refreshLiveState: liveState.refresh,
|
||||
});
|
||||
let threadResume: ThreadResumeController | null = null;
|
||||
const restoredThread = new RestoredThreadController({
|
||||
deferredTasks,
|
||||
opened: lifecycle.getOpened,
|
||||
resumeThread: thread.resumeThread,
|
||||
resumeThread: (threadId) => requireThreadController(threadResume, "thread resume controller").resumeThread(threadId),
|
||||
invalidateResumeWork,
|
||||
stateStore,
|
||||
systemItem: state.systemItem,
|
||||
setStatus: status.set,
|
||||
refreshTabHeader: thread.refreshTabHeader,
|
||||
});
|
||||
const threadResume = new ThreadResumeController({
|
||||
threadResume = new ThreadResumeController({
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
resumeWork,
|
||||
|
|
@ -149,7 +162,7 @@ export function createThreadControllerGroup(
|
|||
ensureConnected: client.ensureConnected,
|
||||
closing: lifecycle.getClosing,
|
||||
systemItem: state.systemItem,
|
||||
resetThreadTurnPresence: thread.resetTurnPresence,
|
||||
resetThreadTurnPresence,
|
||||
clearDeferredRestoredThreadHydration: lifecycle.clearDeferredRestoredThreadHydration,
|
||||
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
|
|
@ -168,36 +181,30 @@ export function createThreadControllerGroup(
|
|||
restoredThread,
|
||||
invalidateResumeWork,
|
||||
clearDeferredRestoredThreadHydration: lifecycle.clearDeferredRestoredThreadHydration,
|
||||
resetThreadTurnPresence: thread.resetTurnPresence,
|
||||
resetThreadTurnPresence,
|
||||
notifyActiveThreadIdentityChanged: thread.notifyIdentityChanged,
|
||||
refreshTabHeader: thread.refreshTabHeader,
|
||||
refreshLiveState: liveState.refresh,
|
||||
render: render.now,
|
||||
});
|
||||
const threadRename = new ThreadRenameController({
|
||||
stateStore,
|
||||
vaultPath: plugin.vaultPath,
|
||||
settings: () => plugin.settings,
|
||||
ensureConnected: client.ensureConnected,
|
||||
currentClient: () => refs.connection.currentClient(),
|
||||
refreshThreads: thread.refreshThreads,
|
||||
render: render.shellSlots,
|
||||
addSystemMessage: status.addSystemMessage,
|
||||
notifyThreadRenamed: plugin.notifyThreadRenamed.bind(plugin),
|
||||
});
|
||||
|
||||
return {
|
||||
history,
|
||||
threadActions,
|
||||
goals,
|
||||
restoredThread,
|
||||
threadResume,
|
||||
threadResume: requireThreadController(threadResume, "thread resume controller"),
|
||||
threadIdentity,
|
||||
threadRename,
|
||||
invalidateResumeWork,
|
||||
};
|
||||
}
|
||||
|
||||
function requireThreadController<T>(controller: T | null, name: string): T {
|
||||
if (!controller) throw new Error(`Chat thread controller composition did not initialize ${name}.`);
|
||||
return controller;
|
||||
}
|
||||
|
||||
interface ThreadSelectionControllerGroupPorts {
|
||||
plugin: Pick<CodexChatHost, "focusThreadInOpenView">;
|
||||
state: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue