587 lines
19 KiB
TypeScript
587 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useState } from "react";
|
|
import { type IDockviewHeaderActionsProps } from "dockview-react";
|
|
import {
|
|
IconPlus,
|
|
IconMessagePlus,
|
|
IconDeviceDesktop,
|
|
IconTerminal2,
|
|
IconFileText,
|
|
IconFolder,
|
|
IconGitBranch,
|
|
IconGitPullRequest,
|
|
IconPlayerPlay,
|
|
IconLayoutSidebarRightCollapse,
|
|
IconLayoutColumns,
|
|
IconLayoutRows,
|
|
IconX,
|
|
IconBrandVscode,
|
|
IconArrowsMaximize,
|
|
IconArrowsMinimize,
|
|
} from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@kandev/ui/dropdown-menu";
|
|
import { useDockviewStore, performLayoutSwitch } from "@/lib/state/dockview-store";
|
|
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
|
import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
|
|
import { prPanelLabel } from "@/components/github/pr-utils";
|
|
import { startProcess } from "@/lib/api";
|
|
import { createUserShell } from "@/lib/api/domains/user-shell-api";
|
|
import { useRepositoryScripts } from "@/hooks/domains/workspace/use-repository-scripts";
|
|
import { replaceTaskUrl } from "@/lib/links";
|
|
import type { Task, ProcessInfo } from "@/lib/types/http";
|
|
import type { ProcessStatusEntry } from "@/lib/state/slices";
|
|
import { NewSessionDialog } from "./new-session-dialog";
|
|
import { NewTaskDropdown } from "./new-task-dropdown";
|
|
import { SessionReopenMenuItems } from "./session-reopen-menu";
|
|
|
|
/** Map a ProcessInfo response to a ProcessStatusEntry for the store. */
|
|
function mapProcessToStatus(process: ProcessInfo): ProcessStatusEntry {
|
|
return {
|
|
processId: process.id,
|
|
sessionId: process.session_id,
|
|
kind: process.kind,
|
|
scriptName: process.script_name,
|
|
status: process.status,
|
|
command: process.command,
|
|
workingDir: process.working_dir,
|
|
exitCode: process.exit_code ?? null,
|
|
startedAt: process.started_at,
|
|
updatedAt: process.updated_at,
|
|
};
|
|
}
|
|
|
|
function useLeftHeaderState(
|
|
groupId: string,
|
|
containerApi: IDockviewHeaderActionsProps["containerApi"],
|
|
) {
|
|
const sidebarGroupId = useDockviewStore((s) => s.sidebarGroupId);
|
|
const centerGroupId = useDockviewStore((s) => s.centerGroupId);
|
|
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
|
const taskId = useAppStore((state) => state.tasks.activeTaskId);
|
|
const isPassthrough = useAppStore((state) => {
|
|
if (!activeSessionId) return false;
|
|
return state.taskSessions.items[activeSessionId]?.is_passthrough === true;
|
|
});
|
|
const pr = useActiveTaskPR();
|
|
const hasChanges = Boolean(
|
|
containerApi.getPanel("changes") ?? containerApi.getPanel("diff-files"),
|
|
);
|
|
const hasFiles = Boolean(containerApi.getPanel("files") ?? containerApi.getPanel("all-files"));
|
|
return {
|
|
isSidebarGroup: groupId === sidebarGroupId,
|
|
isCenterGroup: groupId === centerGroupId,
|
|
activeSessionId,
|
|
taskId,
|
|
isPassthrough,
|
|
pr,
|
|
hasChanges,
|
|
hasFiles,
|
|
};
|
|
}
|
|
|
|
function AddPanelMenuItems({
|
|
groupId,
|
|
state,
|
|
onNewSession,
|
|
onAddTerminal,
|
|
}: {
|
|
groupId: string;
|
|
state: ReturnType<typeof useLeftHeaderState>;
|
|
onNewSession: () => void;
|
|
onAddTerminal: () => void;
|
|
}) {
|
|
const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel);
|
|
const addVscodePanel = useDockviewStore((s) => s.addVscodePanel);
|
|
const addPlanPanel = useDockviewStore((s) => s.addPlanPanel);
|
|
const addFilesPanel = useDockviewStore((s) => s.addFilesPanel);
|
|
const addChangesPanel = useDockviewStore((s) => s.addChangesPanel);
|
|
const addPRPanel = useDockviewStore((s) => s.addPRPanel);
|
|
|
|
return (
|
|
<>
|
|
{state.taskId && (
|
|
<>
|
|
<DropdownMenuItem
|
|
onClick={onNewSession}
|
|
className="cursor-pointer text-xs"
|
|
data-testid="new-session-button"
|
|
>
|
|
<IconMessagePlus className="h-3.5 w-3.5 mr-1.5" />
|
|
New Agent
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<SessionReopenMenuItems taskId={state.taskId} groupId={groupId} />
|
|
</>
|
|
)}
|
|
<DropdownMenuItem onClick={onAddTerminal} className="cursor-pointer text-xs">
|
|
<IconTerminal2 className="h-3.5 w-3.5 mr-1.5" />
|
|
Terminal
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => addBrowserPanel(undefined, groupId)}
|
|
className="cursor-pointer text-xs"
|
|
>
|
|
<IconDeviceDesktop className="h-3.5 w-3.5 mr-1.5" />
|
|
Browser
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => addVscodePanel()} className="cursor-pointer text-xs">
|
|
<IconBrandVscode className="h-3.5 w-3.5 mr-1.5" />
|
|
VS Code
|
|
</DropdownMenuItem>
|
|
{!state.isPassthrough && (
|
|
<DropdownMenuItem onClick={() => addPlanPanel(groupId)} className="cursor-pointer text-xs">
|
|
<IconFileText className="h-3.5 w-3.5 mr-1.5" />
|
|
Plan
|
|
</DropdownMenuItem>
|
|
)}
|
|
{!state.hasChanges && (
|
|
<DropdownMenuItem
|
|
onClick={() => addChangesPanel(groupId)}
|
|
className="cursor-pointer text-xs"
|
|
>
|
|
<IconGitBranch className="h-3.5 w-3.5 mr-1.5" />
|
|
Changes
|
|
</DropdownMenuItem>
|
|
)}
|
|
{!state.hasFiles && (
|
|
<DropdownMenuItem onClick={() => addFilesPanel(groupId)} className="cursor-pointer text-xs">
|
|
<IconFolder className="h-3.5 w-3.5 mr-1.5" />
|
|
Files
|
|
</DropdownMenuItem>
|
|
)}
|
|
{state.pr && (
|
|
<DropdownMenuItem onClick={() => addPRPanel()} className="cursor-pointer text-xs">
|
|
<IconGitPullRequest className="h-3.5 w-3.5 mr-1.5" />
|
|
{prPanelLabel(state.pr.pr_number)}
|
|
</DropdownMenuItem>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function LeftHeaderActions(props: IDockviewHeaderActionsProps) {
|
|
const { group, containerApi } = props;
|
|
const state = useLeftHeaderState(group.id, containerApi);
|
|
const addTerminalPanel = useDockviewStore((s) => s.addTerminalPanel);
|
|
const [showNewSessionDialog, setShowNewSessionDialog] = useState(false);
|
|
|
|
const handleAddTerminal = useCallback(async () => {
|
|
if (!state.activeSessionId) return;
|
|
try {
|
|
const result = await createUserShell(state.activeSessionId);
|
|
addTerminalPanel(result.terminalId, group.id);
|
|
} catch (error) {
|
|
console.error("Failed to create terminal:", error);
|
|
}
|
|
}, [state.activeSessionId, addTerminalPanel, group.id]);
|
|
|
|
if (state.isSidebarGroup) return null;
|
|
|
|
return (
|
|
<div className="flex items-center gap-1 pl-1">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-6 w-6 p-0 cursor-pointer"
|
|
data-testid="dockview-add-panel-btn"
|
|
>
|
|
<IconPlus className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="w-44">
|
|
<AddPanelMenuItems
|
|
groupId={group.id}
|
|
state={state}
|
|
onNewSession={() => setShowNewSessionDialog(true)}
|
|
onAddTerminal={handleAddTerminal}
|
|
/>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
{state.taskId && (
|
|
<NewSessionDialog
|
|
open={showNewSessionDialog}
|
|
onOpenChange={setShowNewSessionDialog}
|
|
taskId={state.taskId}
|
|
groupId={group.id}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const ACTION_BTN =
|
|
"h-5 w-5 inline-flex items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors cursor-pointer";
|
|
|
|
/** Faded maximize, split, and close buttons for any non-sidebar group. */
|
|
function GroupSplitCloseActions({ group, containerApi }: IDockviewHeaderActionsProps) {
|
|
const centerGroupId = useDockviewStore((s) => s.centerGroupId);
|
|
const isChatGroup = group.id === centerGroupId;
|
|
const isMaximized = useDockviewStore((s) => s.preMaximizeLayout !== null);
|
|
const storeMaximize = useDockviewStore((s) => s.maximizeGroup);
|
|
const storeExitMaximize = useDockviewStore((s) => s.exitMaximizedLayout);
|
|
|
|
const handleMaximize = useCallback(() => {
|
|
if (isMaximized) {
|
|
storeExitMaximize();
|
|
} else {
|
|
storeMaximize(group.id);
|
|
}
|
|
}, [group.id, isMaximized, storeMaximize, storeExitMaximize]);
|
|
|
|
const handleSplitRight = useCallback(() => {
|
|
containerApi.addGroup({ referenceGroup: group, direction: "right" });
|
|
}, [group, containerApi]);
|
|
|
|
const handleSplitDown = useCallback(() => {
|
|
containerApi.addGroup({ referenceGroup: group, direction: "below" });
|
|
}, [group, containerApi]);
|
|
|
|
const handleCloseGroup = useCallback(() => {
|
|
const panels = [...group.panels];
|
|
if (panels.length === 0) {
|
|
try {
|
|
containerApi.removeGroup(group);
|
|
} catch {
|
|
/* already removed */
|
|
}
|
|
return;
|
|
}
|
|
for (const panel of panels) {
|
|
try {
|
|
containerApi.removePanel(panel);
|
|
} catch {
|
|
/* already removed */
|
|
}
|
|
}
|
|
}, [group, containerApi]);
|
|
|
|
return (
|
|
<>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={ACTION_BTN}
|
|
onClick={handleMaximize}
|
|
data-testid="dockview-maximize-btn"
|
|
>
|
|
{isMaximized ? (
|
|
<IconArrowsMinimize className="h-3 w-3" />
|
|
) : (
|
|
<IconArrowsMaximize className="h-3 w-3" />
|
|
)}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{isMaximized ? "Restore" : "Maximize"}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button type="button" className={ACTION_BTN} onClick={handleSplitRight}>
|
|
<IconLayoutColumns className="h-3 w-3" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Split right</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button type="button" className={ACTION_BTN} onClick={handleSplitDown}>
|
|
<IconLayoutRows className="h-3 w-3" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Split down</TooltipContent>
|
|
</Tooltip>
|
|
{!isChatGroup && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={ACTION_BTN}
|
|
onClick={handleCloseGroup}
|
|
data-testid="dockview-close-group-btn"
|
|
>
|
|
<IconX className="h-3 w-3" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Close group</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function RightHeaderActions(props: IDockviewHeaderActionsProps) {
|
|
const { group } = props;
|
|
const centerGroupId = useDockviewStore((s) => s.centerGroupId);
|
|
const sidebarGroupId = useDockviewStore((s) => s.sidebarGroupId);
|
|
const rightTopGroupId = useDockviewStore((s) => s.rightTopGroupId);
|
|
const rightBottomGroupId = useDockviewStore((s) => s.rightBottomGroupId);
|
|
|
|
const isSidebarGroup = group.id === sidebarGroupId;
|
|
if (isSidebarGroup) return <SidebarRightActions />;
|
|
|
|
const isCenterGroup = group.id === centerGroupId;
|
|
const isRightTopGroup = group.id === rightTopGroupId;
|
|
const isTerminalGroup = group.id === rightBottomGroupId;
|
|
|
|
return (
|
|
<div className="flex items-center gap-0.5 pr-1">
|
|
{isCenterGroup && <CenterRightActions />}
|
|
{isRightTopGroup && <RightTopGroupActions />}
|
|
{isTerminalGroup && <TerminalGroupRightActions />}
|
|
<GroupSplitCloseActions {...props} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SidebarRightActions() {
|
|
const workspaceId = useAppStore((state) => state.workspaces.activeId);
|
|
const workflowId = useAppStore((state) => state.workflows.activeId);
|
|
const kanban = useAppStore((state) => state.kanban);
|
|
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
|
const activeTaskTitle = useAppStore((state) => {
|
|
const id = state.tasks.activeTaskId;
|
|
if (!id) return "";
|
|
return state.kanban.tasks.find((t: { id: string }) => t.id === id)?.title ?? "";
|
|
});
|
|
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
|
const setActiveSession = useAppStore((state) => state.setActiveSession);
|
|
const appStore = useAppStoreApi();
|
|
const steps = (kanban?.steps ?? []).map(
|
|
(s: {
|
|
id: string;
|
|
title: string;
|
|
color?: string;
|
|
events?: {
|
|
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
|
|
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
|
|
};
|
|
}) => ({
|
|
id: s.id,
|
|
title: s.title,
|
|
color: s.color,
|
|
events: s.events,
|
|
}),
|
|
);
|
|
|
|
const handleTaskCreated = useCallback(
|
|
(task: Task, _mode: "create" | "edit", meta?: { taskSessionId?: string | null }) => {
|
|
const oldSessionId = appStore.getState().tasks.activeSessionId;
|
|
setActiveTask(task.id);
|
|
if (meta?.taskSessionId) {
|
|
setActiveSession(task.id, meta.taskSessionId);
|
|
performLayoutSwitch(oldSessionId, meta.taskSessionId);
|
|
}
|
|
replaceTaskUrl(task.id);
|
|
},
|
|
[setActiveTask, setActiveSession, appStore],
|
|
);
|
|
|
|
return (
|
|
<div className="flex items-center gap-1 pr-2">
|
|
<NewTaskDropdown
|
|
workspaceId={workspaceId}
|
|
workflowId={workflowId}
|
|
steps={steps}
|
|
activeTaskId={activeTaskId}
|
|
activeTaskTitle={activeTaskTitle}
|
|
onTaskCreated={handleTaskCreated}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RightTopGroupActions() {
|
|
const toggleRightPanels = useDockviewStore((s) => s.toggleRightPanels);
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="h-6 w-6 inline-flex items-center justify-center text-muted-foreground/50 hover:text-foreground transition-colors cursor-pointer"
|
|
onClick={toggleRightPanels}
|
|
>
|
|
<IconLayoutSidebarRightCollapse className="h-3.5 w-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Hide right panels</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function CenterRightActions() {
|
|
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
|
const repository = useAppStore((state) => {
|
|
if (!activeSessionId) return null;
|
|
const session = state.taskSessions.items[activeSessionId];
|
|
if (!session) return null;
|
|
const repoId = session.repository_id;
|
|
if (!repoId) return null;
|
|
const allRepos = Object.values(state.repositories.itemsByWorkspaceId).flat();
|
|
return allRepos.find((r) => r.id === repoId) ?? null;
|
|
});
|
|
const hasDevScript = Boolean(repository?.dev_script?.trim());
|
|
|
|
const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel);
|
|
const upsertProcessStatus = useAppStore((state) => state.upsertProcessStatus);
|
|
const setActiveProcess = useAppStore((state) => state.setActiveProcess);
|
|
|
|
const handleStartBrowser = useCallback(async () => {
|
|
addBrowserPanel();
|
|
if (hasDevScript && activeSessionId) {
|
|
try {
|
|
const resp = await startProcess(activeSessionId, { kind: "dev" });
|
|
if (resp?.process) {
|
|
upsertProcessStatus(mapProcessToStatus(resp.process));
|
|
setActiveProcess(resp.process.session_id, resp.process.id);
|
|
}
|
|
} catch {
|
|
// Process may already be running
|
|
}
|
|
}
|
|
}, [addBrowserPanel, hasDevScript, activeSessionId, upsertProcessStatus, setActiveProcess]);
|
|
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
{/* Mode is shown in the chat input ModeSelector instead */}
|
|
{hasDevScript && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-6 w-6 p-0 cursor-pointer"
|
|
onClick={handleStartBrowser}
|
|
title="Open browser preview"
|
|
>
|
|
<IconDeviceDesktop className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function useHasActiveSessionDevScript() {
|
|
return useAppStore((state) => {
|
|
const sessionId = state.tasks.activeSessionId;
|
|
if (!sessionId) return false;
|
|
const repoId = state.taskSessions.items[sessionId]?.repository_id;
|
|
if (!repoId) return false;
|
|
const allRepos = Object.values(state.repositories.itemsByWorkspaceId).flat();
|
|
const repo = allRepos.find((r) => r.id === repoId);
|
|
return Boolean(repo?.dev_script?.trim());
|
|
});
|
|
}
|
|
|
|
function TerminalGroupRightActions() {
|
|
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
|
const repositoryId = useAppStore((state) => {
|
|
const sessionId = state.tasks.activeSessionId;
|
|
if (!sessionId) return null;
|
|
return state.taskSessions.items[sessionId]?.repository_id ?? null;
|
|
});
|
|
const hasDevScript = useHasActiveSessionDevScript();
|
|
|
|
const { scripts } = useRepositoryScripts(repositoryId);
|
|
const addTerminalPanel = useDockviewStore((s) => s.addTerminalPanel);
|
|
const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel);
|
|
const upsertProcessStatus = useAppStore((state) => state.upsertProcessStatus);
|
|
const setActiveProcess = useAppStore((state) => state.setActiveProcess);
|
|
const rightBottomGroupId = useDockviewStore((s) => s.rightBottomGroupId);
|
|
|
|
const handleRunScript = useCallback(
|
|
async (scriptId: string) => {
|
|
if (!activeSessionId) return;
|
|
try {
|
|
const result = await createUserShell(activeSessionId, scriptId);
|
|
addTerminalPanel(result.terminalId, rightBottomGroupId);
|
|
} catch (error) {
|
|
console.error("Failed to run script:", error);
|
|
}
|
|
},
|
|
[activeSessionId, addTerminalPanel, rightBottomGroupId],
|
|
);
|
|
|
|
const handleStartPreview = useCallback(async () => {
|
|
if (!activeSessionId) return;
|
|
addBrowserPanel();
|
|
try {
|
|
const resp = await startProcess(activeSessionId, { kind: "dev" });
|
|
if (resp?.process) {
|
|
upsertProcessStatus(mapProcessToStatus(resp.process));
|
|
setActiveProcess(resp.process.session_id, resp.process.id);
|
|
}
|
|
} catch {
|
|
// Process may already be running
|
|
}
|
|
try {
|
|
const shell = await createUserShell(activeSessionId);
|
|
addTerminalPanel(shell.terminalId, rightBottomGroupId);
|
|
} catch {
|
|
// Terminal creation is best-effort
|
|
}
|
|
}, [
|
|
activeSessionId,
|
|
addBrowserPanel,
|
|
upsertProcessStatus,
|
|
setActiveProcess,
|
|
addTerminalPanel,
|
|
rightBottomGroupId,
|
|
]);
|
|
|
|
if (scripts.length === 0 && !hasDevScript) return null;
|
|
|
|
return (
|
|
<>
|
|
{scripts.length > 0 && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-6 w-6 p-0 cursor-pointer"
|
|
title="Run script"
|
|
>
|
|
<IconPlayerPlay className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-52">
|
|
{scripts.map((script) => (
|
|
<DropdownMenuItem
|
|
key={script.id}
|
|
onClick={() => handleRunScript(script.id)}
|
|
className="cursor-pointer text-xs"
|
|
>
|
|
<IconTerminal2 className="h-3.5 w-3.5 mr-1.5 shrink-0" />
|
|
<span className="truncate">{script.name}</span>
|
|
<span className="ml-auto text-muted-foreground font-mono text-[10px] truncate max-w-[120px]">
|
|
{script.command}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
{hasDevScript && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-6 w-6 p-0 cursor-pointer"
|
|
onClick={handleStartPreview}
|
|
title="Start dev server preview"
|
|
>
|
|
<IconDeviceDesktop className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</>
|
|
);
|
|
}
|