414 lines
11 KiB
TypeScript
414 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { SymlinkIndicator } from "@/components/shared/symlink-indicator";
|
|
|
|
import { Button } from "@kandev/ui/button";
|
|
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import {
|
|
IconDeviceFloppy,
|
|
IconLoader2,
|
|
IconDownload,
|
|
IconTrash,
|
|
IconTextWrap,
|
|
IconTextWrapDisabled,
|
|
IconMessagePlus,
|
|
IconArrowsDiff,
|
|
IconRefresh,
|
|
IconEye,
|
|
} from "@tabler/icons-react";
|
|
import { formatDiffStats } from "@/lib/utils/file-diff";
|
|
import { toRelativePath } from "@/lib/utils";
|
|
import { FileActionsDropdown } from "@/components/editors/file-actions-dropdown";
|
|
import {
|
|
ExternalVcsFileLink,
|
|
useExternalVcsFileStatus,
|
|
} from "@/components/editors/external-vcs-file-link";
|
|
import { PanelHeaderBarSplit } from "@/components/task/panel-primitives";
|
|
import { LspStatusButton } from "@/components/editors/lsp-status-button";
|
|
import type { FilePreviewKind } from "@/lib/utils/file-types";
|
|
import type { LspStatus } from "@/lib/lsp/lsp-client-manager";
|
|
import type { LspProgressSnapshot } from "@/lib/lsp/lsp-progress";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
const SAVE_SHORTCUT =
|
|
typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "\u2318" : "Ctrl";
|
|
|
|
function SaveButton({
|
|
isDirty,
|
|
isSaving,
|
|
onSave,
|
|
}: {
|
|
isDirty: boolean;
|
|
isSaving: boolean;
|
|
onSave: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Button
|
|
size="sm"
|
|
variant="default"
|
|
onClick={onSave}
|
|
disabled={!isDirty || isSaving}
|
|
className="cursor-pointer gap-2"
|
|
>
|
|
{isSaving ? (
|
|
<>
|
|
<IconLoader2 className="h-4 w-4 animate-spin" />
|
|
{t("editors:saving")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<IconDeviceFloppy className="h-4 w-4" />
|
|
{t("common:save")}
|
|
<span className="text-xs text-muted-foreground">({SAVE_SHORTCUT}+S)</span>
|
|
</>
|
|
)}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function ToolbarLeft({
|
|
path,
|
|
worktreePath,
|
|
isSymlink,
|
|
isDirty,
|
|
diffStats,
|
|
}: {
|
|
path: string;
|
|
worktreePath?: string;
|
|
isSymlink?: boolean;
|
|
isDirty: boolean;
|
|
diffStats: { additions: number; deletions: number } | null;
|
|
}) {
|
|
return (
|
|
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
|
|
<ScrollOnOverflow className="min-w-0 font-mono">
|
|
{toRelativePath(path, worktreePath)}
|
|
</ScrollOnOverflow>
|
|
<SymlinkIndicator isSymlink={isSymlink} showLabel />
|
|
{isDirty && diffStats && (
|
|
<span className="shrink-0 text-xs text-yellow-500">
|
|
{formatDiffStats(diffStats.additions, diffStats.deletions)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CommentCountBadge({
|
|
enableComments,
|
|
sessionId,
|
|
commentCount,
|
|
}: {
|
|
enableComments: boolean;
|
|
sessionId?: string;
|
|
commentCount: number;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
if (!enableComments || !sessionId || commentCount <= 0) return null;
|
|
|
|
return (
|
|
<div className="flex items-center gap-1 px-2 py-1 text-xs text-primary">
|
|
<IconMessagePlus className="h-3.5 w-3.5" />
|
|
<span>{t("editors:commentCount", { count: commentCount })}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DiffIndicatorsButton({
|
|
isVisible,
|
|
onToggle,
|
|
}: {
|
|
isVisible: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const buttonClass = `h-8 w-8 p-0 cursor-pointer ${isVisible ? "text-foreground" : "text-muted-foreground"}`;
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button size="sm" variant="ghost" onClick={onToggle} className={buttonClass}>
|
|
<IconArrowsDiff className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{isVisible ? t("editors:hideDiffIndicators") : t("editors:showDiffIndicators")}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function WrapButton({
|
|
wrapEnabled,
|
|
onToggleWrap,
|
|
}: {
|
|
wrapEnabled: boolean;
|
|
onToggleWrap: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const wrapClass = `h-8 w-8 p-0 cursor-pointer ${wrapEnabled ? "text-foreground" : "text-muted-foreground"}`;
|
|
const wrapIcon = wrapEnabled ? (
|
|
<IconTextWrap className="h-4 w-4" />
|
|
) : (
|
|
<IconTextWrapDisabled className="h-4 w-4" />
|
|
);
|
|
const wrapLabel = wrapEnabled ? t("editors:disableWordWrap") : t("editors:enableWordWrap");
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button size="sm" variant="ghost" onClick={onToggleWrap} className={wrapClass}>
|
|
{wrapIcon}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{wrapLabel}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function ReloadFromAgentButton({
|
|
hasRemoteUpdate,
|
|
onReloadFromAgent,
|
|
}: {
|
|
hasRemoteUpdate?: boolean;
|
|
onReloadFromAgent?: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
if (!hasRemoteUpdate || !onReloadFromAgent) return null;
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-8 cursor-pointer gap-1 px-2 text-xs"
|
|
onClick={onReloadFromAgent}
|
|
>
|
|
<IconRefresh className="h-3.5 w-3.5" />
|
|
{t("editors:reload")}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t("editors:applyLatestAgentChangesToFile")}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function DeleteButton({ onDelete }: { onDelete?: () => void }) {
|
|
const { t } = useTranslation();
|
|
if (!onDelete) return null;
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={onDelete}
|
|
className="h-8 w-8 p-0 cursor-pointer hover:text-destructive"
|
|
>
|
|
<IconTrash className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t("editors:deleteFile")}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function DownloadButton({ onDownload }: { onDownload?: () => void }) {
|
|
const { t } = useTranslation();
|
|
if (!onDownload) return null;
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={onDownload}
|
|
aria-label={t("editors:downloadFile")}
|
|
className="h-11 w-11 p-0 cursor-pointer sm:h-8 sm:w-8"
|
|
>
|
|
<IconDownload className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t("editors:downloadFile")}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
function PreviewButton({
|
|
previewKind,
|
|
onTogglePreview,
|
|
onPreviewHtml,
|
|
isPublishingHtmlPreview,
|
|
}: {
|
|
previewKind: FilePreviewKind;
|
|
onTogglePreview?: () => void;
|
|
onPreviewHtml?: () => void;
|
|
isPublishingHtmlPreview?: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
if (previewKind === "none") return null;
|
|
const isHtml = previewKind === "html";
|
|
const action = isHtml ? onPreviewHtml : onTogglePreview;
|
|
if (!action) return null;
|
|
const label = isHtml ? t("editors:previewHtml") : t("editors:previewMarkdown");
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={action}
|
|
disabled={isHtml && isPublishingHtmlPreview}
|
|
aria-label={label}
|
|
title={isHtml ? t("task:htmlPreviewTrustedCode") : undefined}
|
|
className="h-8 w-8 p-0 cursor-pointer"
|
|
data-testid={isHtml ? "html-preview-toggle" : "markdown-preview-toggle"}
|
|
>
|
|
{isHtml && isPublishingHtmlPreview ? (
|
|
<IconLoader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<IconEye className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{label}</p>
|
|
{isHtml && (
|
|
<p className="mt-1 max-w-xs text-muted-foreground">{t("task:htmlPreviewTrustedCode")}</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
interface MonacoEditorToolbarProps {
|
|
path: string;
|
|
repositoryName?: string;
|
|
worktreePath?: string;
|
|
isSymlink?: boolean;
|
|
isDirty: boolean;
|
|
isSaving: boolean;
|
|
diffStats: { additions: number; deletions: number } | null;
|
|
wrapEnabled: boolean;
|
|
showDiffIndicators: boolean;
|
|
enableComments: boolean;
|
|
sessionId?: string;
|
|
commentCount: number;
|
|
hasRemoteUpdate?: boolean;
|
|
hasVcsDiff?: boolean;
|
|
lspStatus: LspStatus;
|
|
lspProgress: LspProgressSnapshot;
|
|
lspLanguage: string | null;
|
|
showLspStatus?: boolean;
|
|
onToggleLsp: () => void;
|
|
onToggleWrap: () => void;
|
|
onToggleDiffIndicators: () => void;
|
|
onSave: () => void;
|
|
onReloadFromAgent?: () => void;
|
|
onDelete?: () => void;
|
|
onDownload?: () => void;
|
|
previewKind?: FilePreviewKind;
|
|
onTogglePreview?: () => void;
|
|
onPreviewHtml?: () => void;
|
|
isPublishingHtmlPreview?: boolean;
|
|
}
|
|
|
|
export function MonacoEditorToolbar({
|
|
path,
|
|
repositoryName,
|
|
worktreePath,
|
|
isSymlink,
|
|
isDirty,
|
|
isSaving,
|
|
diffStats,
|
|
wrapEnabled,
|
|
showDiffIndicators,
|
|
enableComments,
|
|
sessionId,
|
|
commentCount,
|
|
hasRemoteUpdate = false,
|
|
hasVcsDiff = false,
|
|
lspStatus,
|
|
lspProgress,
|
|
lspLanguage,
|
|
showLspStatus = true,
|
|
onToggleLsp,
|
|
onToggleWrap,
|
|
onToggleDiffIndicators,
|
|
onSave,
|
|
onReloadFromAgent,
|
|
onDelete,
|
|
onDownload,
|
|
previewKind = "none",
|
|
onTogglePreview,
|
|
onPreviewHtml,
|
|
isPublishingHtmlPreview,
|
|
}: MonacoEditorToolbarProps) {
|
|
const fileStatus = useExternalVcsFileStatus(path, sessionId, repositoryName);
|
|
return (
|
|
<PanelHeaderBarSplit
|
|
left={
|
|
<ToolbarLeft
|
|
path={path}
|
|
worktreePath={worktreePath}
|
|
isSymlink={isSymlink}
|
|
isDirty={isDirty}
|
|
diffStats={diffStats}
|
|
/>
|
|
}
|
|
right={
|
|
<div className="flex items-center gap-1">
|
|
<CommentCountBadge
|
|
enableComments={enableComments}
|
|
sessionId={sessionId}
|
|
commentCount={commentCount}
|
|
/>
|
|
{showLspStatus ? (
|
|
<LspStatusButton
|
|
status={lspStatus}
|
|
progress={lspProgress}
|
|
lspLanguage={lspLanguage}
|
|
onToggle={onToggleLsp}
|
|
/>
|
|
) : null}
|
|
{(isDirty || hasVcsDiff) && (
|
|
<DiffIndicatorsButton
|
|
isVisible={showDiffIndicators}
|
|
onToggle={onToggleDiffIndicators}
|
|
/>
|
|
)}
|
|
{(onTogglePreview || onPreviewHtml) && (
|
|
<PreviewButton
|
|
previewKind={previewKind}
|
|
onTogglePreview={onTogglePreview}
|
|
onPreviewHtml={onPreviewHtml}
|
|
isPublishingHtmlPreview={isPublishingHtmlPreview}
|
|
/>
|
|
)}
|
|
<WrapButton wrapEnabled={wrapEnabled} onToggleWrap={onToggleWrap} />
|
|
<ReloadFromAgentButton
|
|
hasRemoteUpdate={hasRemoteUpdate}
|
|
onReloadFromAgent={onReloadFromAgent}
|
|
/>
|
|
<ExternalVcsFileLink
|
|
filePath={path}
|
|
previousPath={fileStatus?.old_path}
|
|
status={fileStatus?.status}
|
|
sessionId={sessionId}
|
|
repositoryName={repositoryName}
|
|
size="sm"
|
|
/>
|
|
<FileActionsDropdown filePath={path} sessionId={sessionId} size="sm" />
|
|
<DownloadButton onDownload={onDownload} />
|
|
<DeleteButton onDelete={onDelete} />
|
|
<SaveButton isDirty={isDirty} isSaving={isSaving} onSave={onSave} />
|
|
</div>
|
|
}
|
|
/>
|
|
);
|
|
}
|